BETA
Skip to content

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/webhooks

Required scope: mail:webhooks

Request Body

FieldTypeRequiredDescription
urlstringYesThe HTTPS URL that will receive webhook events.
eventsstring[]YesThe event types to subscribe to (see Available Events).
enabledbooleanNoWhether the webhook is active. Defaults to true.

Available Events

The dispatcher emits 7 mail events:

EventTriggered when
email.sentSend was accepted by the upstream provider (Amazon SES).
email.deliveredDelivery confirmed by the recipient's server.
email.openedRecipient opened the email.
email.clickedRecipient clicked a tracked link.
email.bouncedEmail bounced (hard or soft).
email.complainedRecipient marked the email as spam.
email.failedSend 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/webhooks

Required 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/:id

Required 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/:id

Required scope: mail:webhooks

Request Body

FieldTypeDescription
urlstringThe HTTPS URL to receive events.
eventsstring[]Event types to subscribe to.
enabledbooleanWhether 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/:id

Required 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 Content

Test 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/test

Required 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-secret

Required 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):

HeaderDescription
content-typeapplication/json
x-krafter-eventThe event name, e.g. email.delivered.
x-krafter-signaturesha256=<lowercase hex> — HMAC-SHA256 of the raw body, keyed with the webhook secret.
x-krafter-webhook-idUUID generated fresh on every delivery attempt. Receivers should dedupe on this.
x-krafter-testtrue — 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:

  1. Read the x-krafter-signature request header. It always has the form sha256=<lowercase hex>.
  2. Strip the sha256= prefix.
  3. Compute HMAC-SHA256(webhook_secret, raw_request_body) where raw_request_body is the exact bytes received (do not re-serialize parsed JSON).
  4. 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
end

Python

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 "", 200

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.

Built by Krafter Studio