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 | sha256=<lowercase hex> — HMAC-SHA256 of the raw body, 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
To verify a webhook signature:
- Read the
x-krafter-signaturerequest header. It always has the formsha256=<lowercase hex>. - Strip the
sha256=prefix. - Compute
HMAC-SHA256(webhook_secret, raw_request_body)whereraw_request_bodyis the exact bytes received (do not re-serialize parsed JSON). - Compare the result to the stripped header value using a constant-time comparison.
Node.js
javascript
const crypto = require("node:crypto");
function verifyKrafterSignature(secret, rawBody, headerValue) {
if (!headerValue || !headerValue.startsWith("sha256=")) return false;
const received = headerValue.slice("sha256=".length);
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
const a = Buffer.from(received, "hex");
const b = Buffer.from(expected, "hex");
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
// Express handler — make sure rawBody is preserved (e.g. via express.raw()).
app.post("/webhooks/email", (req, res) => {
const sig = req.headers["x-krafter-signature"];
if (!verifyKrafterSignature(process.env.KRAFTER_WEBHOOK_SECRET, req.body, sig)) {
return res.status(401).send("Invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
// ... handle event.event and event.payload ...
res.status(200).end();
});Elixir (Phoenix)
elixir
defmodule MyAppWeb.WebhookController do
use MyAppWeb, :controller
def handle(conn, _params) do
{:ok, raw_body, conn} = Plug.Conn.read_body(conn)
[signature] = Plug.Conn.get_req_header(conn, "x-krafter-signature")
secret = System.get_env("KRAFTER_WEBHOOK_SECRET")
if verify(secret, raw_body, signature) do
event = Jason.decode!(raw_body)
handle_event(event)
send_resp(conn, 200, "")
else
send_resp(conn, 401, "invalid signature")
end
end
defp verify(secret, raw_body, header_value) do
with "sha256=" <> received <- header_value,
expected <-
:crypto.mac(:hmac, :sha256, secret, raw_body)
|> Base.encode16(case: :lower),
true <- byte_size(received) == byte_size(expected) do
Plug.Crypto.secure_compare(received, expected)
else
_ -> false
end
end
defp handle_event(%{"event" => "email.delivered", "payload" => p}),
do: IO.puts("Delivered #{p["email_id"]}")
defp handle_event(_), do: :ok
endPython
python
import hmac
import hashlib
def verify_krafter_signature(secret: bytes, raw_body: bytes, header_value: str) -> bool:
prefix = "sha256="
if not header_value or not header_value.startswith(prefix):
return False
received = header_value[len(prefix):]
expected = hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(received, expected)
# Flask handler example
from flask import request, abort
@app.post("/webhooks/email")
def webhook():
raw = request.get_data() # raw bytes — do not use request.json
sig = request.headers.get("X-Krafter-Signature", "")
if not verify_krafter_signature(SECRET, raw, sig):
abort(401)
# ... process event ...
return "", 200Best 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.