Use Kirora from Laravel with a configured API client and verified webhook events. The kirora/kirora-laravel package puts the client in Laravel’s container, with a facade, and turns Kirora’s webhooks into Laravel events, verified before they’re dispatched. Everything else, from services and models to pagination, retries and errors, comes from the PHP library, which this page links to rather than repeats. The README is the full reference.
Install
Add the package with Composer:
composer require kirora/kirora-laravelIt supports Laravel 12, on PHP 8.2 or later, and Laravel 13, on PHP 8.3 or later. Laravel discovers its service provider, so there’s nothing to register.
Create an API key under Developer → Server keys in the dashboard, with the scopes your integration needs, and add it to your app’s .env:
KIRORA_API_KEY=your_api_keyTo check it works, open php artisan tinker and call Kirora\Laravel\Facades\Kirora::me(), which returns the key’s scopes and workspace without changing anything.
Configure
The package reads its settings from your environment, so publishing its configuration is optional:
| Variable | Default | Description |
|---|---|---|
KIRORA_API_KEY |
None | Your server key. Required. |
KIRORA_BASE_URL |
https://api.kirora.io |
The API host, without /v1. For tests and mocks. |
KIRORA_TIMEOUT |
30 | Seconds each attempt may take. |
KIRORA_MAX_RETRIES |
3 | Attempts after the first, from 0 to 10. |
KIRORA_MAX_RETRY_DELAY |
30 | The longest wait between attempts, in seconds. |
KIRORA_WORKSPACE |
None | Partner keys only: the tenant workspace every call acts in. |
KIRORA_WEBHOOK_SECRET |
None | Your webhook endpoint’s signing secret. Required for webhooks. |
KIRORA_WEBHOOK_TOLERANCE |
300 | How far a webhook’s timestamp may be from now, in seconds. |
KIRORA_WEBHOOK_PATH |
kirora/webhook |
The webhook route’s path. |
KIRORA_WEBHOOK_DOMAIN |
Any | Limits the webhook route to one domain. |
KIRORA_WEBHOOK_ROUTE |
true |
false to register the webhook route yourself. |
To add middleware to the webhook route, publish config/kirora.php:
php artisan vendor:publish --tag=kirora-configThe package fills in any webhook setting your file leaves out from its defaults, so a partial webhook array keeps the default path, tolerance and route.
The client checks its settings when it’s first resolved, and a missing key throws an InvalidArgumentException. If you cache configuration, run php artisan config:cache after changing a setting, and restart queue workers and Octane so they pick it up.
Use the client
Inject Kirora\Client, or use the Kirora facade: both give you the same client, created once. With injection, each service is a property, as in the PHP library:
use App\Models\Order;
use Kirora\Client;
class CheckoutController
{
public function __invoke(Client $kirora, Order $order)
{
$kirora->events->track([
'externalId' => (string) auth()->id(),
'name' => 'Order Completed',
'properties' => ['order_id' => (string) $order->id, 'total' => $order->total, 'currency' => $order->currency],
], ['idempotencyKey' => "order-{$order->id}-completed"]);
}
}On the facade, each service is a method, so $kirora->contacts is Kirora::contacts():
use Kirora\Laravel\Facades\Kirora;
$contact = Kirora::contacts()->identify([
'externalId' => 'cust_1042',
'email' => '[email protected]',
]);The client’s own methods, such as me(), forWorkspace() and request(), work as they are. For the services, models, pagination, errors, retries and per-call options, see the PHP library.
Import the facade with use Kirora\Laravel\Facades\Kirora;, and don’t add an alias called Kirora in config/app.php. The PHP library declares a global Kirora class, the one with Kirora::client(), so such an alias never takes effect, and \Kirora::contacts() fails. To use the facade in Blade, alias it under another name, such as KiroraApi.
Partner workspaces
With a partner key, Kirora::forWorkspace($externalId) returns a client that acts in one of your tenants’ workspaces. It’s a new client, and the shared one is unchanged, so make one for each tenant rather than changing configuration between requests or jobs:
$tenant = Kirora::forWorkspace('tenant-42');
$contact = $tenant->contacts->get('cust_1042', ['idType' => 'externalId']);A scoped client reaches its services through properties. See Partner workspaces for managing workspaces and embedding Kirora’s builders.
Receive webhooks
The package registers POST /kirora/webhook, named kirora.webhook, outside the web middleware group, so no session or CSRF check applies to it. To use it:
- In Kirora, add a webhook endpoint at
https://your-app.example/kirora/webhook. See Webhooks. - Set
KIRORA_WEBHOOK_SECRETto that endpoint’s signing secret. - Check the route is there with
php artisan route:list --name=kirora.webhook. - Listen for the events you need.
Each webhook is verified against the raw request body before anything else runs. One with a missing or wrong signature, a timestamp outside the tolerance, or event headers that don’t match the signed body gets an empty 403; the reason is logged at debug level. With no secret set, every webhook is refused with a 500.
A verified webhook dispatches two events from Kirora\Laravel\Events: WebhookReceived for every webhook, then the event for its type:
| Webhook | Event |
|---|---|
contact.created |
ContactCreated |
contact.unsubscribed |
ContactUnsubscribed |
contact.consent_changed |
ContactConsentChanged |
form.submitted |
FormSubmitted |
campaign.sent |
CampaignSent |
journey.enrolled |
JourneyEnrolled |
journey.action_reached |
JourneyActionReached |
message.sent |
MessageSent |
message.delivered |
MessageDelivered |
message.opened |
MessageOpened |
message.clicked |
MessageClicked |
message.bounced |
MessageBounced |
message.complained |
MessageComplained |
message.failed |
MessageFailed |
message.skipped |
MessageSkipped |
webhook.test |
WebhookTest |
A type added after your version of the package dispatches only WebhookReceived. Each event has the decoded body in $payload and Kirora’s headers in $headers, with helpers for eventId(), eventType(), type(), occurredAt(), data() and attempt():
namespace App\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Kirora\Laravel\Events\MessageBounced;
class LogMessageBounce implements ShouldQueue
{
public function handle(MessageBounced $event): void
{
logger()->warning('Kirora message bounced', [
'event_id' => $event->eventId(),
'message_id' => $event->data()['message']['id'] ?? null,
]);
}
}Laravel discovers listeners in app/Listeners by the event their handle() takes; elsewhere, register them with Event::listen(). Listeners match the exact class, so to receive every webhook, listen for WebhookReceived.
The route answers 200 once synchronous listeners have finished and queued ones have been queued. Delivery is at-least-once, so deduplicate on eventId(); your app owns storing and deduplicating events. A queued listener that fails after the 200 isn’t redelivered by Kirora: retry it through Laravel’s queue, and watch for failed jobs.
To move the route, set KIRORA_WEBHOOK_PATH or KIRORA_WEBHOOK_DOMAIN. To register it yourself, set KIRORA_WEBHOOK_ROUTE=false and point your route at the package’s controller, which verifies the signature itself. Keep it outside the web group, or exclude it from CSRF protection:
use Illuminate\Support\Facades\Route;
use Kirora\Laravel\Http\Controllers\WebhookController;
Route::post('hooks/kirora', WebhookController::class);Send transactional email
Send through the API with Kirora::transactional()->send(), using a template from your workspace. The package doesn’t add a Mail transport or work with Mailables; each send goes to one recipient.
use Kirora\Laravel\Facades\Kirora;
$sent = Kirora::transactional()->send([
'to' => ['email' => $user->email],
'data' => ['order_id' => $order->id],
'email' => ['template' => 'order-receipt'], // its alias or its id
], ['idempotencyKey' => "receipt-{$order->id}"]);Pass your own idempotency key, as here, so a retried job replays the first send rather than sending twice. To send your own content instead of a template, pass subject, from, and html or text in email; html can be a rendered Blade view. Send a transactional message covers the result, including channels and copies that weren’t sent.
Sign tokens for the web SDK
The web SDK signs users in with a token from your server. Add the workspace’s SDK token secret to config/services.php, so it’s still read once configuration is cached:
'kirora' => [
'sdk_token_secret' => env('KIRORA_SDK_TOKEN_SECRET'),
],Then sign a token with the PHP library’s UserToken, from a route only a signed-in user can reach:
use Illuminate\Support\Facades\Route;
use Kirora\UserToken;
Route::middleware('auth')->get('/kirora/token', fn () => UserToken::create(
config('services.kirora.sdk_token_secret'),
(string) auth()->id(),
));The PHP page covers token lifetimes, and the web SDK’s setup the browser side.
Test your code
The package doesn’t use Laravel’s Http client, so Http::fake() doesn’t reach it. Instead, bind a PSR-18 client under KiroraServiceProvider::HTTP_CLIENT before the client is first used, such as a Guzzle client with a MockHandler:
use GuzzleHttp\Client as Guzzle;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Kirora\Laravel\Facades\Kirora;
use Kirora\Laravel\KiroraServiceProvider;
it('reads a list', function () {
config(['kirora.api_key' => 'test-key', 'kirora.max_retries' => 0]);
$this->app->instance(KiroraServiceProvider::HTTP_CLIENT, new Guzzle([
'handler' => HandlerStack::create(new MockHandler([
new Response(200, ['Content-Type' => 'application/json'], '{"data":{"id":"beta","name":"Beta testers"}}'),
])),
]));
expect(Kirora::lists()->get('beta')->name)->toBe('Beta testers');
});To test a webhook from end to end, set kirora.webhook.secret in the test, post a body signed with it to /kirora/webhook, and assert the event with Event::fake(). The signature is sha256= followed by the hex HMAC-SHA256 of {timestamp}.{body}; the README has a complete test.