SendKuy API

Laravel

Notifications, a facade and signed webhooks for Laravel 12 and 13.

sendkuy/laravel builds on the PHP SDK. It configures the client from .env, adds a sendkuy notification channel, and turns signed webhooks into Laravel events.

Install

composer require sendkuy/laravel
.env
SENDKUY_API_KEY=sk_live_YOUR_API_KEY

The service provider and the SendKuy facade register themselves.

Send from anywhere

use SendKuy\Laravel\Facades\SendKuy;

$messages = SendKuy::messages()->send([
    'channel' => 'whatsapp',
    'to' => ['+6281234567890'],
    'body' => 'Your order is on its way.',
]);

Type-hint SendKuy\SendKuy to have the same client injected. Every endpoint has a method; see the list of methods.

Notifications

use Illuminate\Notifications\Notification;
use SendKuy\Laravel\Notifications\SendKuyMessage;

class OrderShipped extends Notification
{
    public function via(object $notifiable): array
    {
        return ['sendkuy'];
    }

    public function toSendKuy(object $notifiable): SendKuyMessage
    {
        return SendKuyMessage::whatsapp('Order #1042 is on its way.')
            ->media('https://example.com/receipts/1042.pdf', 'document');
    }
}

The channel asks the notifiable where to send:

public function routeNotificationForSendKuy(Notification $notification): string
{
    return $this->phone;
}

Return an array to reach several recipients in one message call. Or send without a model: Notification::route('sendkuy', '+6281234567890')->notify(new OrderShipped);. A notifiable without a recipient throws MissingRecipientException before any call is made.

BuilderSets
SendKuyMessage::whatsapp($body), ::sms($body), ::email($subject, $body)the channel and the text
->template($id, $parameters)a WhatsApp template
->media($url, $type, $filename)one WhatsApp attachment
->attach($url, $filename)an email attachment, up to ten
->gateway($id)the exact sender
->scheduleAt($dateTime)send later

A retried queued notification is never sent twice

When a notification implements ShouldQueue and its job may run more than once (tries above 1, or --tries on the worker), Laravel runs it again after a worker dies, a timeout or an exception. The channel derives the idempotency key from Laravel's notification id, which stays the same across every retry of that job, so SendKuy answers a retry with the first result instead of sending again.

Keep toSendKuy() deterministic for this to hold: build the message from the notification's own data, not from now() or other values that change between runs. A retry that produces a different payload under the same key is refused with 409 idempotency_key_reused, so the job fails instead of sending twice. To schedule, store the time on the notification when you create it and pass that to scheduleAt().

A refusal is final for that notification. When SendKuy refuses the send, for example with insufficient_credits or no_sender_available, every retry of the job gets the same refusal under the same key and the job fails. Once you have fixed the cause, send the notification again: a new notification has a new id, and so a new key. Retrying does help with 429 and server errors, which are not remembered.

The channel returns the created messages; a NotificationSent listener reads them from $event->response.

Webhooks

The package registers POST /sendkuy/webhook outside the web middleware group, so CSRF does not apply. Point a webhook at it and store the secret:

.env
SENDKUY_WEBHOOK_SECRET=whsec_...

A delivery with a valid signature becomes a WebhookReceived event and answers 200; anything else answers 403. Without SENDKUY_WEBHOOK_SECRET the route answers 500 and logs an error, so the problem shows up in your delivery history.

use Illuminate\Contracts\Queue\ShouldQueue;
use SendKuy\Laravel\Events\WebhookReceived;

class HandleSendKuyWebhook implements ShouldQueue
{
    public function handle(WebhookReceived $event): void
    {
        if ($event->type === 'message.received') {
            // $event->data is the conversation message; $event->id is X-SendKuy-Event-Id
        }
    }
}

Queue the listener so the route answers within the 10-second timeout. $event->id is the event id from X-SendKuy-Event-Id, which stays the same across every retry of one event: store it and skip ids you have seen. $event->payload holds the whole verified body. New event types reach the listener without a package update.

Configuration

php artisan vendor:publish --tag=sendkuy-config
KeyEnvDefault
api_keySENDKUY_API_KEYnone
base_urlSENDKUY_BASE_URLhttps://app.sendkuy.com/api/v1; change it only to reach a proxy or a mock server in tests
timeoutSENDKUY_TIMEOUT30 seconds
max_retriesSENDKUY_MAX_RETRIES2
webhook.secretSENDKUY_WEBHOOK_SECRETnone
webhook.pathSENDKUY_WEBHOOK_PATHsendkuy/webhook; empty turns the route off
webhook.toleranceSENDKUY_WEBHOOK_TOLERANCE300 seconds

Testing

Notification::fake() works as usual. To fake the client itself, bind your own instance with a PSR-18 client:

$this->app->instance(\SendKuy\SendKuy::class, new \SendKuy\SendKuy('sk_test_123', [
    'http_client' => $fakeClient,
]));