Welcome, {{name}}.
" \ --vars '{"name":"Ada"}' \ --interpolate # Email with cc and attachment senderkit send-raw user@example.com \ --channel email \ --subject "Your invoice" \ --html "Please find your invoice attached.
" \ --cc "finance@acme.com" \ --attachments '[{"filename":"invoice.pdf","contentType":"application/pdf","content":"Hi there.
" \ --from "hello@acme.com" \ --from-name "Acme" # SMS senderkit send-raw +14155551234 \ --channel sms \ --body "Your verification code is 042195" # Push senderkit send-raw "dev_token_…" \ --channel push \ --title "Order shipped" \ --body "Tracking attached" \ --badge 1 \ --push-data '{"deeplink":"app://orders/9"}' # Web Push (pass the JSON-serialised PushSubscription as the recipient) senderkit send-raw '{"endpoint":"https://fcm.googleapis.com/…","keys":{"p256dh":"…","auth":"…"}}' \ --channel web-push \ --title "New message" \ --body "You have a new message from Ada." \ --icon "https://example.com/icon.png" \ --click-url "https://example.com/messages" ``` ## When to reach for the CLI vs the SDK Use the CLI for ad-hoc work: triggering a real send while building a template, scripting backfills, wiring up cron jobs that pipe through `jq`. For application code, use the [TypeScript SDK](/sdks/typescript) — it adds automatic retries, batch helpers, and types.@senderkit/sdk and call send().
: ` from the underlying API. e.g.
`template_not_found: No template "welcom"`, `rate_limited: Too many requests`.
* **Auth** — `unauthorized` on a missing or invalid API key.
* **Permission** — `insufficient_scope` when the key is valid but doesn't hold
the scope required by the called tool. See
[Authentication → Scopes](/authentication#scopes).
If your agent isn't doing what you expect, ask it to print the raw tool error.
The text matches the [REST API's](/api-reference/introduction) `code` /
`message` pair, so anything in the API troubleshooting guide applies here too.
How a send becomes a delivered message.
The lifecycle behind every tool response.
Provision addresses and receive mail as a `message.received` webhook.
The REST surface backing these tools.
Connect a client and start calling tools.
# Quickstart
Source: https://docs.senderkit.com/quickstart
Create a template, send it from your app, then change the copy without a redeploy.
In about five minutes you'll do the one thing that makes SenderKit different:
create a message template in the dashboard, wire up a single `send` call, and then
change the wording from the dashboard — no code change, no redeploy. The subject,
layout, and copy live outside your repo; your code just names the template and fills
in the variables.
In the [dashboard](https://senderkit.com/app/templates), create an `email`
[template](/concepts/templates) with the slug `welcome`. Write a subject and body,
and reference dynamic values as [variables](/concepts/variables) with Mustache
braces:
```text Template copy (in the dashboard editor) theme={null}
Subject: Welcome to Acme, {{name}} 👋
Hi {{name}},
Thanks for signing up — we're glad you're here.
```
The slug `welcome` is the stable name your code will send to. Staring at a blank
editor? [AI authoring](/concepts/ai-authoring) drafts the subject, layout, and
variables from a one-line brief.
You don't need to publish yet. In test mode SenderKit renders your **latest
draft**, so you can keep editing copy as you go. Live sends only ever render the
[published version](/concepts/versioning).
Create a key in the [dashboard](https://senderkit.com/app/api-keys). Use an
`sk_test_` key while you're wiring things up — test keys never call your providers,
so nothing reaches a real inbox — and switch to `sk_live_` to send for real. The
plaintext is shown once at creation, so copy it then.
Reference the template by slug, pass the recipient and `vars`, and send. Pick your
surface:
Install the SDK:
```bash npm theme={null}
npm install @senderkit/sdk
```
```bash pnpm theme={null}
pnpm add @senderkit/sdk
```
```bash yarn theme={null}
yarn add @senderkit/sdk
```
```bash bun theme={null}
bun add @senderkit/sdk
```
Keep your key in the environment (out of source), then create a client and send:
```bash theme={null}
export SENDERKIT_API_KEY="sk_test_..."
```
```ts theme={null}
import { SenderKit } from "@senderkit/sdk";
const senderkit = new SenderKit({ apiKey: process.env.SENDERKIT_API_KEY! });
const result = await senderkit.send({
template: "welcome",
to: "user@example.com",
vars: { name: "Ada" },
});
console.log(result); // { id: "msg_…", status: "queued", livemode: false }
```
The core SDK has zero runtime dependencies and uses native `fetch` (Node.js 18+).
Keep the key server-side — never ship it in a client bundle.
```bash theme={null}
curl -X POST https://api.senderkit.com/v1/send \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{
"template": "welcome",
"to": "user@example.com",
"vars": { "name": "Ada" }
}'
```
Returns `202 Accepted`:
```json theme={null}
{ "id": "msg_…", "status": "queued", "livemode": false }
```
```bash theme={null}
npm install -g @senderkit/cli
senderkit login # paste your sk_test_… key when prompted
senderkit send welcome user@example.com --vars '{"name":"Ada"}'
```
```
✓ Queued message msg_…
id: msg_…
status: queued
mode: test
```
See the full [CLI reference](/cli/send) for options like `--metadata` and
`--scheduled-at`.
`vars` fills the `{{name}}` holes in your template. Sends are asynchronous: the call
returns `status: "queued"` once SenderKit accepts the message (or `"scheduled"` if you
passed a future `scheduledAt`), and delivery happens out of band.
The send is accepted immediately as `queued` and delivered asynchronously — in test
mode without ever touching a real provider, so nothing reaches an inbox. Look the
message up in the dashboard, or fetch it by the `id` you got back:
```ts theme={null}
const message = await senderkit.messages.get(result.id);
console.log(message.status, message.timeline);
```
You can also list recent sends with `senderkit.messages.list({ template: "welcome" })`
(CLI: `senderkit messages list`; HTTP: `GET /v1/messages`). See
[Messages](/concepts/messages) for how a send moves from queued to delivered.
Here's the payoff. Go back to the `welcome` template in the dashboard, change the
subject or body, and run the **exact same send again**. The new wording renders —
you didn't touch a line of code or ship a deploy.
That's the whole idea: `send({ template: "welcome" })` is a stable contract, and
everything that changes without engineering — the words, the layout, the subject —
lives on the dashboard side of it. Anyone on the team can fix a typo without opening
your editor.
**Publish** the `welcome` template, then swap your key to `sk_live_`. No code changes —
SenderKit [derives live vs. test](/concepts/environments) from the key prefix, and your
published template renders for real.
## What's next
Trigger this send automatically from a Clerk or Auth.js event.
Key types, scopes, and the test vs. live model.
Slugs, channels, and why copy edits never touch your code.
Track a send from queued to delivered.
Working with an AI assistant? The [MCP server](/mcp/overview) exposes these same
operations to agents in Claude, Cursor, and other MCP clients.
# Laravel
Source: https://docs.senderkit.com/sdks/laravel
SenderKit for Laravel — notification channel, mail transport, and webhook middleware.
`senderkit/senderkit-laravel` integrates the
[PHP SDK core](/sdks/php) with Laravel's notification system, mail
infrastructure, and HTTP routing.
## Install
```bash theme={null}
composer require senderkit/senderkit-laravel
php artisan vendor:publish --tag=senderkit-config
```
Add credentials to `.env`:
```bash theme={null}
SENDERKIT_API_KEY=sk_live_…
SENDERKIT_WEBHOOK_SECRET=whsec_… # required for webhook middleware
```
The service provider auto-discovers via Laravel's package discovery. You can
also resolve `SenderKit\Client` directly from the container, or use the
`SenderKit` facade:
```php theme={null}
use SenderKit\Laravel\Facades\SenderKit;
use SenderKit\Request\TemplateSend;
SenderKit::send(new TemplateSend(template: 'welcome', to: $user->email));
SenderKit::messages()->list();
SenderKit::templates()->list();
```
***
## Notification channel
The `senderkit` channel delivers template-based notifications across all four
channels (email, SMS, push, web push). It is the recommended path for new
code — templates are versioned, previewable in the dashboard, and carry
per-template analytics.
Return a `SenderKitMessage` from `toSenderKit()`. For email notifications the
recipient resolves from the notifiable's `senderkit` route, falling back to
the `mail` route — so a `User` model with an `email` property works without
any extra setup:
```php theme={null}
use Illuminate\Notifications\Notification;
use SenderKit\Laravel\Notifications\SenderKitMessage;
class OrderShipped extends Notification
{
public function __construct(private Order $order) {}
public function via(object $notifiable): array
{
return ['senderkit'];
}
public function toSenderKit(object $notifiable): SenderKitMessage
{
return SenderKitMessage::template('order-shipped')
->vars(['order_id' => $this->order->id, 'name' => $notifiable->name]);
}
}
```
`SenderKitMessage` supports the full set of send options:
| Method | Description |
| ----------------------------------------------- | ------------------------------- |
| `->vars(array $vars)` | Template variables |
| `->channel(Channel $channel)` | Force a channel (see below) |
| `->version(int $version)` | Pin a template version |
| `->metadata(array $metadata)` | Attach metadata to the message |
| `->scheduledAt(\DateTimeInterface\|string $at)` | Defer delivery |
| `->cc(array $addresses)` | Cc (email only) |
| `->bcc(array $addresses)` | Bcc (email only) |
| `->replyTo(string $address)` | Reply-To (email only) |
| `->attachments(array $attachments)` | Attachments (email only) |
| `->idempotencyKey(string $key)` | Explicit idempotency key |
| `->to(string $recipient)` | Override the resolved recipient |
### SMS, push, and web push
Email notifications fall back to the notifiable's `mail` route automatically.
For other channels, expose a `routeNotificationForSenderkit()` method that
returns either a string (used for all channels) or a channel-keyed map:
```php theme={null}
use SenderKit\Enum\Channel;
// On the notifiable (e.g. User):
public function routeNotificationForSenderkit(): array
{
return [
'email' => $this->email,
'sms' => $this->phone,
'push' => $this->device_token,
'web-push' => $this->web_push_subscription,
];
}
// In the notification:
public function toSenderKit(object $notifiable): SenderKitMessage
{
return SenderKitMessage::template('otp')
->channel(Channel::Sms)
->vars(['code' => $this->code]);
}
```
SMS, push, and web push are silently skipped if no matching route is found and
no explicit `->to()` is set. Only email falls back to the `mail` route.
### Fan-out across channels
`toSenderKit()` can return an array of `SenderKitMessage` objects to dispatch
the notification on multiple channels in a single call:
```php theme={null}
public function toSenderKit(object $notifiable): array
{
$vars = ['order_id' => $this->order->id, 'name' => $notifiable->name];
return [
SenderKitMessage::template('order-shipped')->vars($vars),
SenderKitMessage::template('order-shipped')->vars($vars)->channel(Channel::Push),
];
}
```
***
## Mail transport
The `senderkit` mail driver routes an existing Mailable-based app through
SenderKit without code changes — no need to rewrite `Mail::to(...)->send(...)`
calls, queued mail jobs, or `mail`-channel notifications.
Add a mailer entry to `config/mail.php`:
```php theme={null}
'mailers' => [
'senderkit' => ['transport' => 'senderkit'],
],
```
Then set `MAIL_MAILER=senderkit`. All outgoing mail now delivers through
SenderKit.
The transport renders your Mailable to HTML locally, then uses a **raw
send** — bypassing SenderKit templates. You lose versioning, dashboard
preview, and per-template analytics. The mail transport is the right path
for **migrating** existing Mailable-based code. For new notifications,
prefer the `senderkit` notification channel above.
A few things to know:
* **Text-only emails** — the transport generates an escaped-HTML fallback
automatically (the API requires an HTML body).
* **Multiple `to` recipients** — fanned out as one API call per recipient.
***
## Webhooks
The `VerifyWebhookSignature` middleware verifies the incoming request
signature and attaches the parsed event to `$request->attributes`.
```php theme={null}
use SenderKit\Laravel\Http\Middleware\VerifyWebhookSignature;
Route::post('/webhooks/senderkit', function (Request $request) {
/** @var \SenderKit\Webhook\WebhookEvent $event */
$event = $request->attributes->get('senderkit_event');
match ($event->type) {
'message.delivered' => handleDelivered($event->payload),
'message.failed' => handleFailed($event->payload),
default => null,
};
return response()->noContent();
})->middleware(VerifyWebhookSignature::class);
```
* Invalid signatures → `400`
* Missing `senderkit.webhook_secret` config → `500`
See [Webhooks](/webhooks) for the full event-type list and payload schema.
Client API, error handling, and bare-PHP webhook verifier.
Bundle autowiring and Symfony webhook verifier.
Event types and payload schema.
API keys, scopes, and test vs. live mode.
# PHP SDK
Source: https://docs.senderkit.com/sdks/php
The official SenderKit PHP SDK — framework-agnostic core for PHP 8.1+.
The PHP SDK ships as three Composer packages. This page covers the
**framework-agnostic core** (`senderkit/senderkit-php`). For framework
integrations see the dedicated pages:
Service provider, notification channel, mail transport, and webhook middleware.
Bundle with autowiring and a webhook request verifier.
## Requirements
* PHP 8.1+
* A PSR-18 HTTP client — Guzzle or `symfony/http-client` are auto-discovered;
you can also inject your own.
## Install
```bash theme={null}
composer require senderkit/senderkit-php
```
## Quickstart
```php theme={null}
use SenderKit\Client;
use SenderKit\Request\TemplateSend;
$sk = new Client(apiKey: getenv('SENDERKIT_API_KEY'));
$result = $sk->send(new TemplateSend(
template: 'welcome',
to: 'user@example.com',
vars: ['name' => 'Ada'],
));
echo $result->id; // msg_…
echo $result->status; // queued | scheduled
```
## Client construction
```php theme={null}
new SenderKit\Client(
string $apiKey,
string $baseUrl = 'https://api.senderkit.com',
int $timeoutMs = 30000,
int $maxRetries = 2,
?Psr\Http\Client\ClientInterface $httpClient = null,
?Psr\Http\Message\RequestFactoryInterface $requestFactory = null,
?Psr\Http\Message\StreamFactoryInterface $streamFactory = null,
)
```
Your API key — must start with `sk_live_` (live mode) or `sk_test_` (test
mode). The constructor throws `\InvalidArgumentException` for any other
prefix. See [Authentication](/authentication).
Override the API base URL. Useful for proxies or self-hosted gateways.
Per-request timeout in milliseconds.
Max retry attempts for transient failures — network errors, timeouts, `429`,
and `5xx`. Retries use exponential backoff with jitter.
Inject a PSR-18 HTTP client. Defaults to auto-discovery via
`php-http/discovery` (Guzzle or `symfony/http-client` if either is
installed).
The `$client->mode` property (`'live'` or `'test'`) is set as a read-only
value after construction, derived from the API key prefix.
## `send()`
Send a [templated message](/concepts/templates), substituting
[variables](/concepts/variables) at send time.
```php theme={null}
$client->send(TemplateSend $request): SendResult
```
Template slug, e.g. `'welcome'`.
Recipient address — email, phone number, or push token.
Template variables. Defaults to `[]`.
Force a channel (`Channel::Email`, `Channel::Sms`, `Channel::Push`,
`Channel::WebPush`). Defaults to the template's primary channel.
Pin a specific template [version](/concepts/versioning). Omit to use the
current published version for the environment.
Free-form metadata attached to the message. Indexed server-side, so you can
later filter with `messages->list(new ListMessagesParams(metadata: [...]))`.
Defer delivery to a future time — a `DateTimeInterface` or an ISO 8601
string. Must be in the future and within 30 days. See
[Sending](/concepts/sending).
Idempotency key. **If omitted, the SDK auto-generates one** so a retried
request never duplicates a send. Reusing a key returns the original message.
Cc / Bcc recipients. Email only.
Reply-To address. Email only.
File or inline attachments (email only). Each `Attachment` takes `filename`,
`contentType`, `content` (base64-encoded bytes), and optional
`inline`/`contentId`. Provider caps total across all attachments at 10 MB.
### Response
`SendResult` has three properties: `id` (e.g. `"msg_…"`), `status`
(`"queued"` or `"scheduled"`), and `livemode` (bool).
```php theme={null}
$result = $client->send(new TemplateSend(
template: 'receipt',
to: 'user@example.com',
vars: ['amount' => '$42.00'],
cc: ['accounting@acme.com'],
metadata: ['orderId' => 'ord_9'],
idempotencyKey: 'receipt:ord_9',
));
// $result->id → "msg_…"
// $result->status → "queued"
// $result->livemode → true
```
## `sendRaw()`
Send inline content without a registered template. Pass one of the typed
content classes — the channel is inferred from the content type.
```php theme={null}
$client->sendRaw(RawSend $request): SendResult
```
Recipient address.
Typed content object — determines the channel.
Email only. Must match a
[verified custom sending domain](/concepts/channels-and-providers#custom-sending-domains)
when using the managed email sender.
Set `true` to run server-side variable substitution over the raw content
using the `vars` values.
```php theme={null}
use SenderKit\Request\{RawSend, EmailContent};
$client->sendRaw(new RawSend(
to: 'user@example.com',
content: new EmailContent(
subject: 'Welcome, {{name}}',
html: 'Hello, {{name}}.
',
),
vars: ['name' => 'Ada'],
interpolate: true,
));
```
```php theme={null}
use SenderKit\Request\{RawSend, SmsContent};
$client->sendRaw(new RawSend(
to: '+14155551234',
content: new SmsContent(body: 'Your code is 042195'),
));
```
```php theme={null}
use SenderKit\Request\{RawSend, PushContent};
$client->sendRaw(new RawSend(
to: 'device_token_…',
content: new PushContent(
title: 'Order shipped',
body: 'Tracking attached',
badge: 1,
),
));
```
```php theme={null}
use SenderKit\Request\{RawSend, WebPushContent};
$client->sendRaw(new RawSend(
to: json_encode($subscription), // JSON-encoded browser PushSubscription
content: new WebPushContent(
title: 'New order shipped',
body: 'Your package is on its way.',
clickUrl: 'https://acme.com/orders/ord_123',
),
));
```
## `sendBatch()`
Send many messages sequentially with per-item error isolation. A failure on
one item never throws — each result carries a success flag so the rest of the
batch is not affected.
```php theme={null}
/** @param list $requests */
$client->sendBatch(array $requests, ?BatchOptions $options = null): list
```
Base idempotency key. Each item is dispatched with `{key}-{index}` unless
the item already carries its own key.
Each `BatchResult`:
* `$result->ok` — `true` on success, `false` on failure.
* `$result->index` — position in the input array.
* `$result->result` — `SendResult` when `ok === true`.
* `$result->error` — `SenderKitException` when `ok === false`.
```php theme={null}
use SenderKit\Request\{BatchOptions, TemplateSend};
$results = $client->sendBatch(
array_map(fn($to) => new TemplateSend('digest', $to), $recipients),
new BatchOptions(idempotencyKey: 'digest:2026-05-31'),
);
$failed = array_filter($results, fn($r) => !$r->ok);
if (count($failed) > 0) {
error_log(count($failed) . ' sends failed');
}
```
## `context()`
Fetch the workspace the API key belongs to and the active send mode.
```php theme={null}
$client->context(): Context
```
Returns a `Context` object with `workspace` (`id`, `slug`, `name`) and `mode`
(`'live'` or `'test'`). Mirrors
[`GET /v1/context`](/api-reference/introduction).
```php theme={null}
$ctx = $client->context();
echo $ctx->workspace->name; // "Acme Inc"
echo $ctx->mode; // "live"
```
## Templates
```php theme={null}
$client->templates->list(): array // array
$client->templates->get(string $slug): Template
```
`list()` returns templates without their version body. `get($slug)` includes
`currentVersion` (`versionNumber`, `variables`, `publishedAt`). A `Template`
has `slug`, `channel`, `description`, `status`, and `updatedAt`.
The `content` (raw HTML/blocks) field is intentionally omitted from `list()`
and `get()` responses to keep payloads lean. Use the dashboard or the
`/v1/templates/{slug}/render` endpoint when you need the rendered output.
```php theme={null}
$templates = $client->templates->list();
$welcome = $client->templates->get('welcome');
echo $welcome->currentVersion?->versionNumber;
```
## Messages
```php theme={null}
$client->messages->list(?ListMessagesParams $params = null): ListMessagesResponse
$client->messages->get(string $id): Message
$client->messages->cancel(string $id): CancelMessageResponse
```
`cancel()` only works on `scheduled` or `queued` messages — later states
return a `409` (`ApiException`). `list()` returns `data` (array of `Message`)
and `nextCursor` (string or null).
```php theme={null}
use SenderKit\Request\ListMessagesParams;
$cursor = null;
do {
$page = $client->messages->list(new ListMessagesParams(
status: 'failed',
channel: 'email',
cursor: $cursor,
limit: 100,
));
foreach ($page->data as $msg) {
echo $msg->id . ' ' . $msg->recipient . PHP_EOL;
}
$cursor = $page->nextCursor;
} while ($cursor !== null);
$client->messages->cancel('msg_…');
```
## Error handling
All exceptions extend `SenderKitException` (which extends `\RuntimeException`).
API errors carry `$status`, `$apiCode`, `$issues`, and `$requestId` (quote
`$requestId` in support requests).
| Class | Thrown when | Extra properties |
| -------------------------------- | -------------------------------------- | ---------------------------------------------- |
| `ApiException` | Any non-2xx (e.g. `403`, `409`, `5xx`) | `$status`, `$apiCode`, `$issues`, `$requestId` |
| `AuthenticationException` | `401` — bad, missing, or revoked key | (inherits `ApiException`) |
| `ValidationException` | `400` / `422` — invalid request | (inherits `ApiException`) |
| `RateLimitException` | `429` — rate limited | `$retryAfterMs` (milliseconds) + inherited |
| `TimeoutException` | Request exceeded `$timeoutMs` | — |
| `NetworkException` | Network-level failure | `$cause` |
| `SignatureVerificationException` | Invalid or expired webhook signature | — |
The SDK retries `429`, `5xx`, network, and timeout failures up to `$maxRetries`
with backoff, so a thrown exception means retries were exhausted.
A `403 insufficient_scope` error (scoped key used outside its grant) comes back
as `ApiException` with `$status = 403` and `$apiCode = "insufficient_scope"`.
See [Authentication → Scopes](/authentication#scopes).
```php theme={null}
use SenderKit\Exception\{
AuthenticationException,
ValidationException,
RateLimitException,
ApiException,
SenderKitException,
};
try {
$client->send(new TemplateSend('welcome', 'user@example.com'));
} catch (ValidationException $e) {
var_dump($e->issues);
} catch (AuthenticationException $e) {
// Invalid or revoked key
} catch (RateLimitException $e) {
usleep($e->retryAfterMs * 1000);
} catch (ApiException $e) {
error_log("API error {$e->status}: {$e->apiCode}");
} catch (SenderKitException $e) {
// Network or timeout
}
```
## Webhooks
```php theme={null}
use SenderKit\Webhook\WebhookVerifier;
use SenderKit\Exception\SignatureVerificationException;
try {
$event = (new WebhookVerifier)->verify(
rawBody: file_get_contents('php://input'),
signatureHeader: $_SERVER['HTTP_X_SENDERKIT_SIGNATURE'],
secret: getenv('SENDERKIT_WEBHOOK_SECRET'), // whsec_…
);
} catch (SignatureVerificationException $e) {
http_response_code(400);
exit;
}
echo $event->type; // e.g. "message.delivered"
$event->payload; // decoded JSON body as array
```
`verify()` checks the HMAC-SHA256 signature and validates that the timestamp
is within 300 seconds (configurable via `$toleranceSeconds`). It throws
`SignatureVerificationException` on any failure — empty secret, malformed
header, stale timestamp, or signature mismatch.
See [Webhooks](/webhooks) for the full event-type list and payload schema.
Notification channel, mail transport, and webhook middleware.
Bundle autowiring and webhook verifier.
Channels, scheduling, and delivery lifecycle.
The underlying REST endpoints.
# Symfony
Source: https://docs.senderkit.com/sdks/symfony
SenderKit for Symfony — bundle with autowiring and a webhook request verifier.
`senderkit/senderkit-symfony` integrates the
[PHP SDK core](/sdks/php) with Symfony's service container and HTTP
foundation.
## Install
```bash theme={null}
composer require senderkit/senderkit-symfony
```
Symfony Flex registers the bundle automatically. If you are not using Flex,
add it to `config/bundles.php`:
```php theme={null}
return [
// …
SenderKit\Symfony\SenderKitBundle::class => ['all' => true],
];
```
## Configuration
Create `config/packages/senderkit.yaml`:
```yaml theme={null}
senderkit:
api_key: '%env(SENDERKIT_API_KEY)%'
webhook_secret: '%env(SENDERKIT_WEBHOOK_SECRET)%' # optional; required for webhook verification
```
Set the environment variables:
```bash theme={null}
SENDERKIT_API_KEY=sk_live_…
SENDERKIT_WEBHOOK_SECRET=whsec_…
```
When `symfony/http-client` is installed, the bundle automatically uses the
framework's PSR-18 HTTP client. Any other PSR-18 client discovered by
`php-http/discovery` works too.
## Usage
Autowire `SenderKit\Client` into any service or controller:
```php theme={null}
use SenderKit\Client;
use SenderKit\Request\TemplateSend;
class NotificationService
{
public function __construct(private readonly Client $senderkit) {}
public function welcome(string $email, string $name): void
{
$this->senderkit->send(new TemplateSend(
template: 'welcome',
to: $email,
vars: ['name' => $name],
));
}
}
```
All methods on `Client` are available — see the [PHP SDK core](/sdks/php) for
the full API (`sendRaw`, `sendBatch`, `context`, `messages`, `templates`).
## Webhooks
Inject `SenderKit\Symfony\Webhook\RequestVerifier` and call `verify()`:
```php theme={null}
use SenderKit\Symfony\Webhook\RequestVerifier;
use SenderKit\Exception\SignatureVerificationException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class WebhookController
{
public function __construct(private readonly RequestVerifier $verifier) {}
#[Route('/webhooks/senderkit', methods: ['POST'])]
public function handle(Request $request): Response
{
try {
$event = $this->verifier->verify($request);
} catch (SignatureVerificationException $e) {
return new Response('Bad signature', 400);
}
match ($event->type) {
'message.delivered' => $this->handleDelivered($event->payload),
'message.failed' => $this->handleFailed($event->payload),
default => null,
};
return new Response('', 204);
}
}
```
`RequestVerifier::verify()` throws:
* `SignatureVerificationException` — invalid or expired signature.
* `\RuntimeException` — `webhook_secret` is not configured.
See [Webhooks](/webhooks) for the full event-type list and payload schema.
Client API, error handling, and bare-PHP webhook verifier.
Notification channel, mail transport, and webhook middleware.
Event types and payload schema.
API keys, scopes, and test vs. live mode.
# TypeScript SDK
Source: https://docs.senderkit.com/sdks/typescript
The official @senderkit/sdk client for Node.js and edge runtimes.
`@senderkit/sdk` is the official TypeScript client. It is ESM + CJS,
zero-dependency, fully typed, and runs on Node.js 18+ and edge runtimes (it uses
the global `fetch`). It wraps the [REST API](/api-reference/introduction) with
typed requests, automatic retries with backoff, idempotency, and batch helpers.
## Install
```bash npm theme={null}
npm install @senderkit/sdk
```
```bash pnpm theme={null}
pnpm add @senderkit/sdk
```
```bash bun theme={null}
bun add @senderkit/sdk
```
## Quickstart
```ts theme={null}
import { SenderKit } from "@senderkit/sdk";
const senderkit = new SenderKit({ apiKey: process.env.SENDERKIT_API_KEY! });
const result = await senderkit.send({
template: "welcome",
to: "user@example.com",
vars: { name: "Ada" },
});
console.log(result.id); // "msg_…"
```
## Client construction
```ts theme={null}
new SenderKit(options: SenderKitOptions)
```
Your API key. Must start with `sk_live_` (live mode) or `sk_test_` (test
mode); the constructor throws a `TypeError` otherwise. The SDK does **not**
read `process.env` for you — pass it explicitly. See
[Authentication](/authentication).
Override the API base URL. Useful for pointing at a proxy or a self-hosted
gateway.
Per-request timeout in milliseconds. A timed-out request rejects with
`SenderKitTimeoutError` (after retries are exhausted).
Max retry attempts for transient failures — network errors, timeouts, `429`,
and `5xx` (except `501`). Retries use exponential backoff with jitter and
honor a `Retry-After` response header.
Inject a custom `fetch` implementation — handy for tests or edge runtimes
that don't expose a global `fetch`. Defaults to `globalThis.fetch`.
Read-only property (not a constructor option). Derived from the API key
prefix: `sk_test_…` → `"test"`, anything else → `"live"`. Useful for
conditional logging or safety checks: `if (senderkit.mode === "test") …`.
## `send()`
Send a [templated message](/concepts/templates), interpolating
[variables](/concepts/variables) at send time.
```ts theme={null}
senderkit.send(request: SendRequest): Promise
```
Template slug, e.g. `"welcome"`.
Recipient address — email address, E.164 phone number, push device token, or
JSON-encoded web-push `PushSubscription`, depending on the resolved channel.
A non-E.164 SMS recipient is rejected.
Template variables. Defaults to `{}`.
Force a channel. Defaults to the template's primary channel.
Pin a specific template [version](/concepts/versioning). Omit to use the
current published version for the environment.
Free-form metadata attached to the message. Indexed server-side, so you can
later filter with `messages.list({ metadata })`.
Defer delivery to a future time — an ISO 8601 string or a `Date`. Must be in
the future and within 30 days. The response comes back with
`status: "scheduled"`. See [Sending](/concepts/sending).
Idempotency key. **If omitted, the SDK auto-generates one** so a retried
request never duplicates a send. Reusing a key returns the original message.
Cc recipients. Email only. Max 50 addresses.
Bcc recipients. Email only. Max 50 addresses.
Reply-To address. Email only.
File or inline attachments (email only). Each `Attachment` is
`{ filename, contentType, content, inline?, contentId? }` where `content` is
**base64-encoded bytes**. Provider caps the total across all attachments at
10 MB.
Per-message From address override (email only, bare address). Falls back to
the connection's From address. On managed sending it's honored only on the
workspace's [verified custom sending domain](/concepts/channels-and-providers#custom-sending-domains).
Per-message From display name override (email only), rendered as
`Name `. Falls back to the connection's From name. Max 128
characters; no control characters or angle brackets. Unlike `from`, it
always applies regardless of sending domain.
### Response
Message id, e.g. `"msg_…"`.
`"scheduled"` when `scheduledAt` is in the future, otherwise `"queued"`.
Whether the request ran against live mode. Derived from the API key prefix.
```ts theme={null}
const res = await senderkit.send({
template: "receipt",
to: "user@example.com",
vars: { amount: "$42.00" },
cc: ["accounting@acme.com"],
metadata: { orderId: "ord_9" },
idempotencyKey: "receipt:ord_9",
});
// { id: "msg_…", status: "queued", livemode: true }
```
## `sendRaw()`
Send inline content without a registered template. The `content` shape is
selected by `channel`.
```ts theme={null}
senderkit.sendRaw(request: SendRawRequest): Promise
```
`SendRawRequest` is a discriminated union on `channel`. Shared fields: `to`
(required), `vars`, `metadata`, `interpolate`, `scheduledAt`, `idempotencyKey`.
By default content is delivered verbatim — set `interpolate: true` to run
server-side variable substitution over it. Returns the same `SendResponse` as
`send()`.
`content: RawEmailContent` — `{ subject, html, preheader?, text? }` plus the
email envelope fields (`cc`, `bcc`, `replyTo`, `attachments`). Two top-level
From overrides are also available, identical to `send()`: `from?` (bare
address; must match a
[verified custom sending domain](/concepts/channels-and-providers#custom-sending-domains)
on managed sending) and `fromName?` (display name, always applies).
```ts theme={null}
await senderkit.sendRaw({
channel: "email",
to: "user@example.com",
from: "hello@acme.com",
fromName: "Acme",
content: {
subject: "Welcome, {{name}}",
html: "Hello, {{name}}.
",
},
vars: { name: "Ada" },
interpolate: true,
});
```
`content: RawSmsContent` — `{ body }`. `to` must be a valid E.164 phone
number (e.g. `+14155551234`); non-conforming values are rejected.
```ts theme={null}
await senderkit.sendRaw({
channel: "sms",
to: "+14155551234",
content: { body: "Your code is 042195" },
});
```
`content: RawPushContent` — `{ title, body, data?, badge?, sound? }`.
```ts theme={null}
await senderkit.sendRaw({
channel: "push",
to: "device_token_…",
content: { title: "Order shipped", body: "Tracking attached", badge: 1 },
});
```
`content: RawWebPushContent` — `{ title, body, icon?, clickUrl?, badge?, data? }`.
`to` is the JSON-encoded browser `PushSubscription` from the Web Push API.
```ts theme={null}
await senderkit.sendRaw({
channel: "web-push",
to: JSON.stringify(subscription), // PushSubscription from browser
content: {
title: "New order shipped",
body: "Your package is on its way.",
clickUrl: "https://acme.com/orders/ord_123",
},
});
```
## `sendBatch()`
Send many messages with bounded concurrency. Never throws for individual
failures — each result reports success or the error for that item, so one bad
recipient doesn't sink the batch.
```ts theme={null}
senderkit.sendBatch(
requests: Array,
options?: BatchSendOptions,
): Promise
```
Max parallel in-flight requests.
Base key. Each item is sent with `${key}-${index}` (unless the item carries
its own `idempotencyKey`).
Each `BatchSendResult` is one of:
```ts theme={null}
| { ok: true; index: number; id: string; status: "queued" | "scheduled"; livemode: boolean }
| { ok: false; index: number; error: SenderKitError }
```
```ts theme={null}
const results = await senderkit.sendBatch(
recipients.map((to) => ({ template: "digest", to })),
{ concurrency: 10, idempotencyKey: "digest:2026-05-31" },
);
const failed = results.filter((r) => !r.ok);
if (failed.length) console.error(`${failed.length} sends failed`);
```
## `context()`
Fetch the workspace the API key belongs to and the active send mode.
```ts theme={null}
senderkit.context(): Promise
```
Returns `{ workspace: { id, slug, name }, mode: "live" | "test" }`. Useful to
confirm which workspace subsequent calls will affect — mirrors
[`GET /v1/context`](/api-reference/introduction) and the
[`senderkit_context`](/mcp/tools) MCP tool.
```ts theme={null}
const ctx = await senderkit.context();
console.log(ctx.workspace.name); // "Acme Inc"
console.log(ctx.mode); // "live"
```
## Templates
```ts theme={null}
senderkit.templates.list(): Promise
senderkit.templates.get(slug: string): Promise
```
`list()` returns templates without their version body. `get(slug)` includes
`currentVersion` (`{ versionNumber, variables, publishedAt }`). A `Template`
has `slug`, `channel`, `description`, `status`, and `updatedAt`.
The `content` (raw HTML/blocks) field is intentionally omitted from both
`list()` and `get()` responses to keep payloads lean. Use the dashboard or
the `/v1/templates/{slug}/render` endpoint when you need the rendered output.
```ts theme={null}
const templates = await senderkit.templates.list();
const welcome = await senderkit.templates.get("welcome");
console.log(welcome.currentVersion?.versionNumber);
```
## Messages
```ts theme={null}
senderkit.messages.list(params?: ListMessagesParams): Promise
senderkit.messages.get(id: string): Promise
senderkit.messages.cancel(id: string): Promise
```
Max messages to return, 1-200 (default 50).
Pagination cursor — pass the previous response's `nextCursor`.
Filter by [status](/concepts/messages), e.g. `"delivered"`, `"failed"`, `"blocked"`.
Filter by channel.
Filter by template slug.
Filter by metadata attached at send time. Every key/value pair must match.
`list()` resolves to `{ data: Message[]; nextCursor: string | null }`.
`cancel(id)` only works on `scheduled` or `queued` messages — later states
return a `409` (`SenderKitApiError`) — and resolves to
`{ id, status: "canceled" }`.
Message reads are lean — the rendered `content` blob is intentionally omitted
to keep payloads manageable. The `vars`, `timeline`, and `metadata` attached at
send time are still returned on every message.
**`blocked` status** — `Message.status` can be `"blocked"` when outbound abuse
detection halts a send before it reaches a provider. The message timeline records a
generic notice (`"Blocked by automated content safety checks."`); detailed detection
signals are operator-only and not returned in customer API responses. Filter for
blocked messages with `messages.list({ status: "blocked" })`.
**`suppressed` status** — `Message.status` can be `"suppressed"` on managed
(AWS SES) sending: the provider accepted the send but never attempted
delivery, because the recipient address failed validation or was already on
the account's suppression list. It's distinct from `"failed"`, which is a
bounce reported by the receiving mail server after an actual delivery
attempt. Bring-your-own-provider connections never return `"suppressed"`.
Subscribe to the `message.suppressed` [webhook event](/webhooks) to be
notified as it happens.
**Engagement** — `Message.openedAt` / `Message.clickedAt` record the first
provider-reported email open / link click as an ISO 8601 string, or `null` if
it hasn't happened yet. Each is set once, on the first occurrence — later
opens or clicks don't update it. Subscribe to the `message.opened` /
`message.clicked` [webhook events](/webhooks) to be notified as they happen.
```ts theme={null}
// Page through every failed email
let cursor: string | undefined;
do {
const page = await senderkit.messages.list({
status: "failed",
channel: "email",
cursor,
limit: 100,
});
for (const m of page.data) console.log(m.publicId, m.recipient);
cursor = page.nextCursor ?? undefined;
} while (cursor);
// Cancel a scheduled message
await senderkit.messages.cancel("msg_…");
```
## Inbound
Receive email, not just send it. Requires an API key with the `inbound`
[scope](/authentication#scopes). See [Inbound Email](/concepts/inbound) for
the full concept reference.
```ts theme={null}
senderkit.inbound.addresses.list(): Promise
senderkit.inbound.addresses.create(params?: CreateInboundAddressParams): Promise
senderkit.inbound.addresses.delete(id: string): Promise
senderkit.inbound.messages.list(params?: ListInboundMessagesParams): Promise
senderkit.inbound.messages.get(id: string): Promise
senderkit.inbound.messages.raw(id: string): Promise
senderkit.inbound.messages.attachment(id: string, index: number): Promise
senderkit.inbound.domains.list(): Promise
senderkit.inbound.domains.create(params: CreateInboundDomainParams): Promise
senderkit.inbound.domains.delete(id: string): Promise
```
```ts theme={null}
// Provision an address on the shared receiving domain
const address = await senderkit.inbound.addresses.create({
localPart: "support",
forwardTo: "team@acme.com",
});
// Claim a custom domain and publish the DNS records it returns
const domain = await senderkit.inbound.domains.create({ domain: "inbound.acme.com" });
for (const record of domain.records) {
console.log(record.type, record.name, record.value);
}
// Mint an address on that domain once it verifies, plus a catch-all
await senderkit.inbound.addresses.create({ domainId: domain.id, localPart: "invoices" });
await senderkit.inbound.addresses.create({ domainId: domain.id, localPart: "*" });
// Read what arrived
const recent = await senderkit.inbound.messages.list({ limit: 20 });
const full = await senderkit.inbound.messages.get(recent[0].id);
console.log(full.subject, full.text);
```
1–64 chars of `a-z 0-9 . _ -`, starting and ending alphanumeric. Omit to
auto-generate an unguessable `rcv-xxxxxxxxxx` local part. Pass `"*"` for a
catch-all that receives every local part on its domain that no exact
address already claims — an exact-match address always takes priority.
A verified custom domain's id (from `domains.list()`) to mint the address
on. Omit for the workspace's shared `{slug}.in.senderkit.email` domain.
Test-mode (`false`) addresses still receive real mail and fan out to
test-mode webhook endpoints, but any `forwardTo` is recorded as a test send
rather than actually delivered. Every address, test or live, counts toward
the inbound-address plan limit — only received *messages* on a test-mode
address skip the message quota.
Also forward received mail to a real inbox. Can't point at another inbound
address (rejected as a mail loop).
Bind the address to one specific webhook endpoint — it receives
`message.received` even if not itself subscribed, but must be active and
match the address's `livemode`. Left unset, `message.received` fans out to
every active endpoint subscribed to it in the address's mode.
Domain to claim for receiving, e.g. `"inbound.acme.com"`. Must not already
be claimed and must not be a `senderkit.com`/`senderkit.email` suffix.
Only set `true` after the user has confirmed. Omit on the first attempt —
a domain with live MX records pointing elsewhere rejects with a `409`
(`SenderKitApiError`, `code: "existing_mx"`) naming the current host(s), so
you can confirm before redirecting all of that domain's mail to SenderKit.
`domains.create()` returns the DNS `records` to publish — an MX plus a DKIM
TXT proving ownership, unless the domain already has a verified
[custom sending domain](/concepts/channels-and-providers#custom-sending-domains)
on the workspace, which reuses that identity and needs only the MX. Nothing
is received until the records are live and a background sweep flips the
domain's `status` from `"pending"` to `"verified"` (or `"failed"` after the
same verification window as custom sending domains).
`messages.raw()` and `messages.attachment(id, index)` return the original
`message/rfc822` source and one attachment's bytes as a `BinaryResponse`;
both are only available for 30 days after receipt.
## Error handling
Every error extends `SenderKitError`. API errors carry `status`, `code`,
`issues`, and `requestId` (echoed from the `x-request-id` response header — quote
it in support requests).
| Class | Thrown when | Extra properties |
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| `SenderKitAuthenticationError` | `401` — bad, missing, or revoked key | `status`, `code`, `issues`, `requestId` |
| `SenderKitPermissionError` | `403` — key is valid but lacks the required scope; extends `SenderKitApiError` (see [Scopes](/authentication#scopes)) | `status`, `code`, `issues`, `requestId` |
| `SenderKitValidationError` | `400` / `422` — invalid request | `status`, `code`, `issues`, `requestId` |
| `SenderKitRateLimitError` | `429` — rate limited | `retryAfter` (**milliseconds**, when the server sent `Retry-After`) + the above |
| `SenderKitApiError` | any other non-2xx (e.g. `409`, `5xx`) | `status`, `code`, `issues`, `requestId` |
| `SenderKitTimeoutError` | request exceeded `timeout` | — |
| `SenderKitNetworkError` | network-level failure | `cause` |
The SDK already retries transient failures (`429`, `5xx`, network, timeout) up
to `maxRetries` with backoff, so a thrown error means retries were exhausted.
```ts theme={null}
import {
SenderKit,
SenderKitPermissionError,
SenderKitRateLimitError,
SenderKitValidationError,
SenderKitError,
} from "@senderkit/sdk";
try {
await senderkit.send({ template: "welcome", to: "user@example.com" });
} catch (err) {
if (err instanceof SenderKitValidationError) {
console.error("Bad request:", err.issues);
} else if (err instanceof SenderKitPermissionError) {
console.error("Permission denied — key lacks required scope:", err.code);
} else if (err instanceof SenderKitRateLimitError) {
console.error(`Rate limited; retry after ${err.retryAfter}ms`);
} else if (err instanceof SenderKitError) {
console.error("Send failed:", err.message);
} else {
throw err;
}
}
```
## Exports
The package exports the `SenderKit` class, every error class above, the
`VERSION` constant, and types including `SenderKitOptions`, `SenderKitContext`,
`ApiScope`, `SendRequest`, `SendRawRequest`, `SendResponse`, `BatchSendOptions`,
`BatchSendResult`, `Attachment`, `EmailEnvelope`, `Template`, `Message`,
`ListMessagesParams`, `ListMessagesResponse`, `CancelMessageResponse`,
`InboundAddress`, `CreateInboundAddressParams`, `DeleteInboundAddressResponse`,
`InboundMessage`, `InboundMessageSummary`, `InboundMessageStatus`,
`InboundAttachment`, `ListInboundMessagesParams`, `InboundDomain`,
`InboundDnsRecord`, `CreateInboundDomainParams`,
`DeleteInboundDomainResponse`, and `BinaryResponse`.
What a send accepts now and delivers later.
The lifecycle behind `messages.list` and `cancel`.
Receive mail on your workspace's domain or your own.
Integrate without the SDK, via raw `fetch`.
The underlying REST endpoints.
# Webhooks
Source: https://docs.senderkit.com/webhooks
Receive real-time push notifications when async message outcomes resolve.
SenderKit sends messages asynchronously. When you call `send()`, you get back a
message id and `status: queued` immediately — but the outcomes that matter
(delivery confirmation, bounces, opt-outs) happen seconds or minutes later inside
the provider. Webhooks let SenderKit push those outcomes to your backend the moment
they arrive, rather than making you poll. The same channel also delivers mail
sent to your [inbound addresses](/concepts/inbound).
## Events
Webhooks fire only for asynchronous outcomes you can't predict from the API response.
Internal pipeline states (`queued`, `rendered`) are not emitted.
| Event | When it fires |
| -------------------- | ------------------------------------------------------------------------------------------------------------- |
| `message.sent` | The message was handed off to the email/SMS/push provider |
| `message.delivered` | The provider confirmed delivery to the recipient |
| `message.failed` | The message bounced, errored, or exhausted retries |
| `message.suppressed` | The send was skipped before dispatch — the recipient failed validation or was already on the suppression list |
| `message.opted_out` | The recipient unsubscribed, via a one-click link or a provider event |
| `message.complained` | The provider reported the message as spam |
| `message.opened` | The provider reported the recipient opened the email (first occurrence only) |
| `message.clicked` | The provider reported a link click in the email (first occurrence only) |
| `message.received` | Mail arrived at one of your [inbound addresses](/concepts/inbound) |
`message.opened` and `message.clicked` are engagement signals, not lifecycle
states — they never change a message's `status`. `delivered` remains the
terminal happy-path status; an open or click can arrive seconds or minutes
after it. Both fire only once, on the message's first reported open/click.
`message.opted_out` and `message.complained` are consent/reputation signals,
not lifecycle states — like opens and clicks, they don't retroactively change
a message's `status`. A message that was already `delivered` (or `failed`)
when the recipient unsubscribes or complains keeps that status; only a send
skipped **before** dispatch because the recipient had already opted out gets
the `opted_out` status itself (see [Messages](/concepts/messages#the-message-lifecycle)).
A spam complaint fires both `message.complained` and `message.opted_out`; a
plain unsubscribe fires `message.opted_out` alone.
`message.suppressed` fires on managed (AWS SES) sending only, when the
recipient address fails SES validation or is already on the account's
suppression list — the send is skipped before it ever reaches a provider,
and the message lands in the terminal `suppressed` status. It's distinct
from `message.failed`, which reports a real bounce from the receiving mail
server after an actual delivery attempt. Bring-your-own-provider connections
never emit `message.suppressed`.
## Setting up an endpoint
1. Open **Webhooks** from the sidebar in your dashboard (`/app/webhooks`).
2. Click **Add endpoint** and paste your HTTPS URL.
3. Copy the **signing secret** shown after creation — it is displayed only once
and cannot be retrieved later.
4. Choose which events to subscribe to (or leave all selected to receive everything).
5. Click **Send test event** to confirm your endpoint receives and verifies the
payload correctly before going live.
Webhooks deliver to **live mode** endpoints only. In test mode, delivery is
simulated in-process — no real HTTP requests are made to your endpoint.
## Payload
Every event is a `POST` with `Content-Type: application/json`. The body follows a
consistent envelope:
```json theme={null}
{
"id": "evt_01HZ…",
"type": "message.delivered",
"created": "2026-06-01T12:34:56.789Z",
"livemode": true,
"data": {
"message": {
"id": "msg_01HZ…",
"status": "delivered",
"channel": "email",
"recipient": "user@example.com",
"provider": "ses",
"metadata": {},
"error": null,
"openedAt": null,
"clickedAt": null,
"createdAt": "2026-06-01T12:34:00.000Z"
}
}
}
```
`data.message` is a public projection of the message — it omits rendered HTML,
template variables, and internal provider message IDs. `openedAt` / `clickedAt`
are set once, on the message's first reported open/click; a `message.clicked`
event additionally carries the clicked URL as `data.link`:
```json theme={null}
{
"id": "evt_01HZ…",
"type": "message.clicked",
"created": "2026-06-01T12:36:10.000Z",
"livemode": true,
"data": {
"message": {
"id": "msg_01HZ…",
"status": "delivered",
"channel": "email",
"recipient": "user@example.com",
"provider": "ses",
"metadata": {},
"error": null,
"openedAt": "2026-06-01T12:35:02.000Z",
"clickedAt": "2026-06-01T12:36:10.000Z",
"createdAt": "2026-06-01T12:34:00.000Z"
},
"link": "https://acme.com/orders/ord_9"
}
}
```
### `message.received` payloads
`message.received` carries a different `data.message` shape — a received
message, not a send. It's only emitted for mail that matched an active
[inbound address](/concepts/inbound); unmatched or quota-exceeded mail is
recorded but never delivered as a webhook.
```json theme={null}
{
"id": "evt_01HZ…",
"type": "message.received",
"created": "2026-07-24T12:00:00.000Z",
"livemode": true,
"data": {
"message": {
"id": "rcv_01HZ…",
"channel": "email",
"address": "support@acme.in.senderkit.email",
"plusTag": null,
"from": { "email": "customer@example.com", "name": "Jamie Customer" },
"to": [{ "email": "support@acme.in.senderkit.email", "name": null }],
"cc": [],
"envelope": {
"from": "customer@example.com",
"to": ["support@acme.in.senderkit.email"]
},
"subject": "Question about my order",
"messageId": "",
"inReplyTo": null,
"text": "Hi, I have a question about order #4821…",
"html": "Hi, I have a question about order #4821…
",
"strippedReply": "Hi, I have a question about order #4821…",
"truncated": false,
"headers": { "date": "Fri, 24 Jul 2026 12:00:00 +0000" },
"attachments": [],
"verdicts": { "spam": "pass", "virus": "pass", "spf": "pass", "dkim": "pass" },
"sizeBytes": 4213,
"rawUrl": "https://api.senderkit.com/v1/inbound/messages/rcv_01HZ…/raw",
"receivedAt": "2026-07-24T12:00:00.000Z"
}
}
}
```
`attachments[].url` and `rawUrl` are authenticated API links, not signed
public URLs — fetch them with an `Authorization: Bearer` header carrying an
API key with the `inbound` [scope](/authentication#scopes).
## Verifying signatures
Every webhook request carries three headers:
| Header | Value |
| ----------------------- | ------------------------------------------- |
| `X-SenderKit-Event` | The event type, e.g. `message.delivered` |
| `X-SenderKit-Delivery` | Unique delivery ID, e.g. `whd_01HZ…` |
| `X-SenderKit-Signature` | HMAC-SHA256 signature for replay protection |
The signature format is:
```
t=,v1=
```
To verify it, compute `HMAC-SHA256(key=, data=".")`
and compare with the `v1` value. Reject the event if the signature doesn't match or
if the timestamp is more than 5 minutes old.
### Verification example (Node.js)
```ts theme={null}
import { createHmac, timingSafeEqual } from "crypto";
function verifyWebhook(
rawBody: string,
signature: string,
secret: string,
toleranceSec = 300
): boolean {
const parts = Object.fromEntries(
signature.split(",").map((p) => p.split("=") as [string, string])
);
const timestamp = parts["t"];
const expected = parts["v1"];
if (!timestamp || !expected) return false;
const age = Math.floor(Date.now() / 1000) - Number(timestamp);
if (age > toleranceSec) return false;
const digest = createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
return timingSafeEqual(Buffer.from(digest), Buffer.from(expected));
}
```
Always use a constant-time comparison (`timingSafeEqual`) to prevent timing
attacks. Never compare signatures with `===`.
### Express example
```ts theme={null}
import express from "express";
import { verifyWebhook } from "./webhooks"; // your verification helper
const app = express();
app.post(
"/webhooks/senderkit",
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.headers["x-senderkit-signature"] as string;
const secret = process.env.SENDERKIT_WEBHOOK_SECRET!;
if (!verifyWebhook(req.body.toString(), sig, secret)) {
return res.status(400).send("Invalid signature");
}
const { type, data } = JSON.parse(req.body.toString());
// Acknowledge immediately, process asynchronously
res.sendStatus(200);
if (type === "message.failed") {
// e.g. alert on failed delivery
}
}
);
```
## Retries and delivery logs
SenderKit retries failed deliveries automatically on any non-`2xx` response or
network error. Each endpoint retries independently — a slow or unavailable endpoint
does not block delivery to your other endpoints.
You can inspect delivery history in the **Webhooks** dashboard. Each endpoint shows
recent attempts, HTTP status codes, response times, and whether retries are pending.
Return `2xx` as quickly as possible and process the event asynchronously in your
backend. Long-running handlers risk timing out and triggering a retry.
The message lifecycle and the statuses that trigger webhook events.
Provision addresses and receive mail as a `message.received` webhook.