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:sendandmessages:read.webhooks:writeincludeswebhooks:read. Anagentkey cannot holdwebhooks: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_URLandKIRORA_API_KEYset 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
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.jsonThe 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
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:
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:
// 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:
// 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:
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:
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.