Receive delivery eventsMenu
On this page

Receive delivery events

Updated

Subscribe a webhook, send an email, and verify and handle the message.delivered event it reports.

This recipe takes message.delivered from end to end: you run a receiver, subscribe it, check it with a test event, send an email, and handle the event that reports its delivery.

message.delivered is email only. It means the recipient’s mail server accepted the message, not that it reached the inbox rather than the spam folder.

Every POST here carries an Idempotency-Key, so running a step twice is safe: a retry repeats nothing (see Idempotency). If you change a request’s body, change its key too, or the API returns 409 IDEMPOTENCY_KEY_REUSED.

Before you start

  • An API key with webhooks:write, transactional:send and messages:read. webhooks:write includes webhooks:read. An agent key cannot hold webhooks:write. See Authentication.
  • A verified sending domain for the email’s from.
  • A public HTTPS address for your receiver. Kirora sends only to HTTPS URLs that resolve to a public host, and does not follow redirects, so give the exact final URL. On your own machine, expose the receiver through an HTTPS tunnel.
  • Node.js, cURL and jq, and KIRORA_API_URL and KIRORA_API_KEY set as in the Quickstart.

1 Run a receiver

Start from the complete receiver in Node.js: it reads the raw body, verifies the signature, saves each event once by its eventId, and returns 200. Connect its saveEventOnce placeholder to your database or queue before you rely on it. It listens on /webhooks/kirora.

2 Subscribe

Terminal
curl "$KIRORA_API_URL/v1/webhooks" \
  --fail-with-body --silent --show-error -o webhook.json \
  -H "Authorization: Bearer $KIRORA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: recipe-delivery-webhook-001" \
  -d '{
    "url": "https://hooks.juniperkitchen.com/webhooks/kirora",
    "events": ["message.delivered"]
  }'
WEBHOOK_ID=$(jq -er '.data.id' webhook.json)
jq -er '.data.secret' webhook.json

The response is 201. data.secret is the signing secret. It is shown only here, and again only as a new secret when you rotate it: store it in your secret manager, and start the receiver with it as KIRORA_WEBHOOK_SECRET. A URL that is already subscribed returns 409 ALREADY_EXISTS; change its events with PATCH /v1/webhooks/{id} instead.

3 Check it with a test event

Terminal
curl -X POST "$KIRORA_API_URL/v1/webhooks/$WEBHOOK_ID/test" \
  --fail-with-body --silent --show-error \
  -H "Authorization: Bearer $KIRORA_API_KEY" \
  -H "Idempotency-Key: recipe-delivery-test-001"

The response is 202 with the delivery. Your receiver gets a signed webhook.test event, whatever events the subscription names. If it returns 401, the secret or the raw-body capture is wrong; see Verifying the signature.

4 Send an email

[email protected] is a test address: the email goes through every check a real send does, is reported as delivered, and reaches no inbox. Replace mail.juniperkitchen.com with your verified domain:

Terminal
curl "$KIRORA_API_URL/v1/transactional/messages" \
  --fail-with-body --silent --show-error -o send.json \
  -H "Authorization: Bearer $KIRORA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: recipe-delivery-send-001" \
  -d '{
    "to": { "email": "[email protected]" },
    "correlationId": "order-1001-receipt",
    "email": {
      "subject": "Your receipt",
      "from": "Juniper Kitchen <[email protected]>",
      "html": "<p>Thanks for your order.</p>"
    }
  }'
MESSAGE_ID=$(jq -er '.data.messages[0].id' send.json)

The response is 202: the message is stored as queued, not yet sent. correlationId is your own reference, and every webhook about the message carries it.

5 Handle the event

Your receiver gets a message.delivered event. The headers carry X-Webhook-Event: message.delivered and the event id, and the body is the envelope:

JSON excerpt
// The request body of the message.delivered delivery, with data.message shortened
{
  "eventId": "01a0f2cf-4444-7555-8666-777788889999",
  "eventType": "message.delivered",
  "occurredAt": "2026-10-23T09:30:04.000Z",
  "data": {
    "message": {
      "id": "0192f3b0-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
      "status": "delivered",
      "correlationId": "order-1001-receipt",
      "simulated": true,
      "deliveredAt": "2026-10-23T09:30:04.000Z"
      // …and the rest of the OutboundMessage
    },
    "providerMessageId": "simulated-0192f3b0-1a2b-7c3d-8e4f-5a6b7c8d9e0f"
  }
}

simulated: true marks an outcome acted out for a test address; a real delivery carries false. The receiver has already saved the event. The worker that reads it does the work, for example:

JavaScript
// In your worker: one saved event at a time.
async function handleEvent(event) {
  if (event.eventType !== 'message.delivered') return; // Ignore types you do not use.
  const { id, correlationId, deliveredAt } = event.data.message;
  // Record the delivery against your own order, keyed by the message id, so a
  // repeated event changes nothing. updateOrderEmailStatus is your own code.
  await updateOrderEmailStatus(correlationId, { messageId: id, deliveredAt });
}

Events arrive at least once and in no fixed order. Keep the change keyed by the message id, as here, and compare timestamps such as deliveredAt rather than trusting the order of arrival.

6 Check what was sent

Read the message:

Terminal
curl "$KIRORA_API_URL/v1/messages/$MESSAGE_ID" \
  --fail-with-body --silent --show-error \
  -H "Authorization: Bearer $KIRORA_API_KEY"

Its status is delivered. List the deliveries Kirora made to your endpoint, with the status your receiver returned each time:

Terminal
curl "$KIRORA_API_URL/v1/webhooks/deliveries?webhookId=$WEBHOOK_ID" \
  --fail-with-body --silent --show-error \
  -H "Authorization: Bearer $KIRORA_API_KEY"

A delivery your receiver did not acknowledge with a 2xx is retried on a schedule, and you can resend a failed one with POST /v1/webhooks/deliveries/{deliveryId}/redeliver. See Webhooks.

Next steps