Appearance
Webhooks
Receive real-time notifications when email events occur. Krafter Mail sends signed HTTP POST requests to your configured endpoint for delivery, bounce, open, click, complaint, and failure events.
Base URL: https://app.krafter.dev/api/v1
All webhook management endpoints live under /api/v1/mail/webhooks/* and require the mail:webhooks scope. Signing, retry policy, and auto-disable rules are shared across the platform — see Webhook delivery.
INFO
The legacy scope name webhooks:manage is still accepted on these endpoints for older keys, but the canonical name surfaced in the dashboard is mail:webhooks. New keys should use mail:webhooks.
Create Webhook
Registers a new webhook endpoint. The signing secret is generated server-side and returned only on creation and when rotated. Store it securely — you need it to verify webhook signatures.
POST /mail/webhooksRequired scope: mail:webhooks
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
url | string | Yes | The HTTPS URL that will receive webhook events. |
events | string[] | Yes | The event types to subscribe to (see Available Events). |
enabled | boolean | No | Whether the webhook is active. Defaults to true. |
Available Events
The dispatcher emits 7 mail events:
| Event | Triggered when |
|---|---|
email.sent | Send was accepted by the upstream provider (Amazon SES). |
email.delivered | Delivery confirmed by the recipient's server. |
email.opened | Recipient opened the email. |
email.clicked | Recipient clicked a tracked link. |
email.bounced | Email bounced (hard or soft). |
email.complained | Recipient marked the email as spam. |
email.failed | Send failed (post-retry, non-retryable). |
Example Request
bash
curl -X POST https://app.krafter.dev/api/v1/mail/webhooks \
-H "Authorization: Bearer kr_live_abc123def456" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.yourapp.com/webhooks/email",
"events": ["email.delivered", "email.bounced", "email.opened", "email.clicked"]
}'Example Response
json
// 201 Created
{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"url": "https://api.yourapp.com/webhooks/email",
"events": ["email.delivered", "email.bounced", "email.opened", "email.clicked"],
"enabled": true,
"secret": "k9j8h7g6f5e4d3c2b1a0z9y8x7w6v5u4ABCDEFGhijklmn",
"failure_count": 0,
"last_triggered_at": null,
"created_at": "2026-01-15T10:00:00Z",
"updated_at": "2026-01-15T10:00:00Z"
}
}WARNING
The secret is returned only on creation and when rotated. Save it immediately and store it securely — Krafter cannot show it again.
List Webhooks
Returns every webhook owned by the authenticated team.
GET /mail/webhooksRequired scope: mail:webhooks
Example Request
bash
curl https://app.krafter.dev/api/v1/mail/webhooks \
-H "Authorization: Bearer kr_live_abc123def456"Example Response
json
{
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"url": "https://api.yourapp.com/webhooks/email",
"events": ["email.delivered", "email.bounced", "email.opened", "email.clicked"],
"enabled": true,
"failure_count": 0,
"last_triggered_at": "2026-01-15T11:42:18Z",
"created_at": "2026-01-15T10:00:00Z",
"updated_at": "2026-01-15T10:00:00Z"
}
]
}The secret field is omitted from list and detail responses. Use Rotate Secret to obtain a new value.
Get Webhook
Returns a single webhook by ID.
GET /mail/webhooks/:idRequired scope: mail:webhooks
Example Request
bash
curl https://app.krafter.dev/api/v1/mail/webhooks/550e8400-e29b-41d4-a716-446655440000 \
-H "Authorization: Bearer kr_live_abc123def456"Example Response
json
{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"url": "https://api.yourapp.com/webhooks/email",
"events": ["email.delivered", "email.bounced", "email.opened", "email.clicked"],
"enabled": true,
"failure_count": 0,
"last_triggered_at": "2026-01-15T11:42:18Z",
"created_at": "2026-01-15T10:00:00Z",
"updated_at": "2026-01-15T10:00:00Z"
}
}Update Webhook
Updates an existing webhook. Only the fields you include in the request body are changed.
PATCH /mail/webhooks/:idRequired scope: mail:webhooks
Request Body
| Field | Type | Description |
|---|---|---|
url | string | The HTTPS URL to receive events. |
events | string[] | Event types to subscribe to. |
enabled | boolean | Whether the webhook is active. |
Example Request
bash
curl -X PATCH https://app.krafter.dev/api/v1/mail/webhooks/550e8400-e29b-41d4-a716-446655440000 \
-H "Authorization: Bearer kr_live_abc123def456" \
-H "Content-Type: application/json" \
-d '{
"events": ["email.delivered", "email.bounced", "email.opened", "email.clicked", "email.complained"]
}'Example Response
json
{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"url": "https://api.yourapp.com/webhooks/email",
"events": ["email.delivered", "email.bounced", "email.opened", "email.clicked", "email.complained"],
"enabled": true,
"failure_count": 0,
"last_triggered_at": "2026-01-15T11:42:18Z",
"created_at": "2026-01-15T10:00:00Z",
"updated_at": "2026-01-15T12:00:00Z"
}
}Delete Webhook
Permanently deletes a webhook.
DELETE /mail/webhooks/:idRequired scope: mail:webhooks
Example Request
bash
curl -X DELETE https://app.krafter.dev/api/v1/mail/webhooks/550e8400-e29b-41d4-a716-446655440000 \
-H "Authorization: Bearer kr_live_abc123def456"Example Response
204 No ContentTest Webhook
Sends a synthetic email.delivered event to the webhook URL to verify connectivity. The delivery includes the standard webhook headers plus x-krafter-test: true so receivers can ignore tests entirely.
POST /mail/webhooks/:id/testRequired scope: mail:webhooks
Example Request
bash
curl -X POST https://app.krafter.dev/api/v1/mail/webhooks/550e8400-e29b-41d4-a716-446655440000/test \
-H "Authorization: Bearer kr_live_abc123def456"Success Response
json
// 200 OK
{
"data": {
"webhook_id": "550e8400-e29b-41d4-a716-446655440000",
"test_sent": true,
"status_code": 200,
"message": "Test event delivered to https://api.yourapp.com/webhooks/email"
}
}Failure Response
If the test delivery fails to reach your endpoint, the response is 502 Bad Gateway with a nested error object:
json
// 502 Bad Gateway
{
"error": {
"message": "Test event delivery failed",
"detail": "timeout"
}
}INFO
This nested {error: {message, detail}} shape is unique to this endpoint. Every other Mail endpoint follows one of the four error shapes documented in Error Handling.
Rotate Secret
Generates a new signing secret. The old secret is invalidated immediately. The new secret is returned in the response — store it securely.
POST /mail/webhooks/:id/rotate-secretRequired scope: mail:webhooks
Example Request
bash
curl -X POST https://app.krafter.dev/api/v1/mail/webhooks/550e8400-e29b-41d4-a716-446655440000/rotate-secret \
-H "Authorization: Bearer kr_live_abc123def456"Example Response
json
{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"url": "https://api.yourapp.com/webhooks/email",
"events": ["email.delivered", "email.bounced"],
"enabled": true,
"secret": "n3w5ecr3tk3y1a2b3c4d5e6f7g8h9i0jABCDEFGhijklmn",
"failure_count": 0,
"last_triggered_at": "2026-01-15T11:42:18Z",
"created_at": "2026-01-15T10:00:00Z",
"updated_at": "2026-01-15T13:00:00Z"
}
}Webhook Delivery
When an email event occurs, Krafter Mail sends an HTTP POST request to your endpoint. The body is signed with HMAC-SHA256 over the raw bytes of the request body using your webhook secret.
Request Headers
Every delivery (real and test) carries these request headers (HTTP header names are case-insensitive; Krafter emits them in lowercase):
| Header | Description |
|---|---|
content-type | application/json |
x-krafter-event | The event name, e.g. email.delivered. |
x-krafter-signature | t=<unix>,v1=<hex> — HMAC-SHA256 of <t>.<body>; see Verifying signatures, keyed with the webhook secret. |
x-krafter-webhook-id | UUID generated fresh on every delivery attempt. Receivers should dedupe on this. |
x-krafter-test | true — present only on deliveries from POST /mail/webhooks/:id/test. |
INFO
x-krafter-webhook-id is regenerated on each retry: the same logical event arrives with a different x-krafter-webhook-id on each attempt. Use it to deduplicate delivery attempts; correlate the same logical event by the payload.email_id in the body.
Payload Envelope
Every real delivery wraps the event-specific fields in this top-level envelope:
json
{
"event": "email.delivered",
"timestamp": "2026-01-15T10:30:02Z",
"payload": {
"email_id": "550e8400-e29b-41d4-a716-446655440000",
"from": "billing@yourdomain.com",
"to": ["alice@example.com"],
"subject": "Your invoice is ready"
}
}payload.to is always an array (matching how the email was created). payload.email_id is the UUID of the underlying emails row.
INFO
The synthetic event from POST /mail/webhooks/:id/test uses the same envelope but with a placeholder email_id of "test_<uuid>" and the x-krafter-test: true header. Receivers that branch on x-krafter-test can ignore tests entirely.
Example Payloads
email.delivered
json
{
"event": "email.delivered",
"timestamp": "2026-01-15T10:30:02Z",
"payload": {
"email_id": "550e8400-e29b-41d4-a716-446655440000",
"from": "billing@yourdomain.com",
"to": ["alice@example.com"],
"subject": "Your invoice is ready"
}
}email.opened
json
{
"event": "email.opened",
"timestamp": "2026-01-15T10:31:15Z",
"payload": {
"email_id": "550e8400-e29b-41d4-a716-446655440000",
"to": ["alice@example.com"],
"subject": "Your invoice is ready"
}
}email.clicked
json
{
"event": "email.clicked",
"timestamp": "2026-01-15T10:31:22Z",
"payload": {
"email_id": "550e8400-e29b-41d4-a716-446655440000",
"to": ["alice@example.com"],
"subject": "Your invoice is ready",
"url": "https://app.example.com/invoices/1042"
}
}email.bounced
json
{
"event": "email.bounced",
"timestamp": "2026-01-15T10:30:30Z",
"payload": {
"email_id": "550e8400-e29b-41d4-a716-446655440000",
"from": "billing@yourdomain.com",
"to": ["alice@example.com"],
"subject": "Your invoice is ready",
"bounce_type": "Permanent"
}
}email.failed
json
{
"event": "email.failed",
"timestamp": "2026-01-15T10:30:00Z",
"payload": {
"email_id": "550e8400-e29b-41d4-a716-446655440000",
"from": "billing@yourdomain.com",
"to": ["alice@example.com"],
"subject": "Your invoice is ready",
"error": "Permanent SMTP failure"
}
}Retry Policy
Webhook deliveries are dispatched as Oban jobs with max_attempts: 5 and exponential back-off. After repeated consecutive failures the webhook is automatically disabled (enabled: false); re-enable it via Update Webhook. Successful (2xx) deliveries reset the consecutive-failure counter. Use x-krafter-webhook-id to deduplicate per-attempt — the header is fresh on every retry of the same logical event.
Signature Verification
Every delivery made with a webhook secret carries:
x-krafter-signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>The timestamp is part of the signed payload, so a captured request stops verifying once t goes stale — and a replayer cannot edit t to buy time without invalidating v1.
Verify in three steps: split the header into t and v1, reject anything older than your tolerance (300 seconds recommended), then compare HMAC-SHA256(secret, "<t>." + raw_body) to v1 in constant time. raw_body must be the exact bytes received — re-serialising parsed JSON will not match.
Node.js, Elixir and Python implementations are on Verifying signatures, which is the one place this recipe lives for every Krafter service.
Best Practices
- Always verify the signature before processing the event. Reject unsigned or mismatched requests.
- Use raw bytes, not a re-serialized JSON. Re-encoding can change byte ordering and break the signature.
- Respond with 2xx quickly. Process events asynchronously if needed.
- Deduplicate per attempt on
x-krafter-webhook-id(regenerated per retry). - Use HTTPS only. HTTP endpoints are rejected at create time.
- Rotate secrets periodically with Rotate Secret.