# Receive delivery events

Source: https://kirora.io/docs/api/recipe-delivery-events

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](https://kirora.io/docs/api/reference/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](https://kirora.io/docs/api/reference/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](https://kirora.io/docs/api/quickstart).

## 1. Run a receiver

Start from the [complete receiver in Node.js](https://kirora.io/docs/api/webhooks#a-complete-receiver-in-nodejs):
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

```sh
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

```sh
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](https://kirora.io/docs/api/webhooks#verifying-the-signature).

## 4. Send an email

`delivered@example.com` is a [test address](https://kirora.io/docs/api/transactional#test-addresses): 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:

```sh
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": "delivered@example.com" },
    "correlationId": "order-1001-receipt",
    "email": {
      "subject": "Your receipt",
      "from": "Juniper Kitchen <hello@mail.juniperkitchen.com>",
      "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:

```jsonc
// 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:

```js
// 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:

```sh
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:

```sh
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](https://kirora.io/docs/api/webhooks#retries).

## Next steps

- [Webhooks](https://kirora.io/docs/api/webhooks): every event, signatures, retries and the circuit breaker
- [Message log and exports](https://kirora.io/docs/api/messages): every message status
- [Transactional messages](https://kirora.io/docs/api/transactional#test-addresses): the test addresses for bounces and complaints
- [Go-live checklist](https://kirora.io/docs/api/production-checklist): what to check before real traffic