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/laravelSENDKUY_API_KEY=sk_live_YOUR_API_KEYThe 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.
| Builder | Sets |
|---|---|
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:
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| Key | Env | Default |
|---|---|---|
api_key | SENDKUY_API_KEY | none |
base_url | SENDKUY_BASE_URL | https://app.sendkuy.com/api/v1; change it only to reach a proxy or a mock server in tests |
timeout | SENDKUY_TIMEOUT | 30 seconds |
max_retries | SENDKUY_MAX_RETRIES | 2 |
webhook.secret | SENDKUY_WEBHOOK_SECRET | none |
webhook.path | SENDKUY_WEBHOOK_PATH | sendkuy/webhook; empty turns the route off |
webhook.tolerance | SENDKUY_WEBHOOK_TOLERANCE | 300 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,
]));