# Webhook events

Sendora posts each event as JSON to the webhooks that take it. A delivery may arrive more than once, so key your handling on `id`, and verify `Sendora-Signature` before you trust the body. [Webhooks](https://sendora.se/docs/webhooks) shows how.

## Headers

- `Sendora-Signature` (string): `t=` the Unix time of sending, then `v1=` an HMAC-SHA256 over `<t>.<body>` for each live secret of the webhook, the newest first. Verify one before you trust the body.
- `Sendora-Event` (string): The event, the same as `event` in the body.
- `Sendora-Delivery-Id` (string): The delivery, the same as `id` in the body and on every retry of it.
- `User-Agent` (string): `Sendora-Webhooks/1`.

Acknowledges the delivery. Any other answer, or none within 10 seconds, is retried.

## Message events

Every event in this group carries these fields:

- `id` (string, required): The delivery's id. A delivery may arrive more than once, so key your handling on it.
- `attempt` (integer, required): The attempt number, from 1.
- `messageId` (string, required): The message the event is about.
- `recipient` (string, required): The recipient the event is about.
- `occurredAt` (string (date-time), required): When it happened.
- `serverId` (string, required): The server the message was sent from.
- `streamId` (string, required): The stream the message went on.
- `broadcastId` (string or null, required): The broadcast the message belongs to; null for a message sent on its own.
- `tag` (string or null, required): The tag you sent with the message.
- `metadata` (map of string to string, required): The metadata you sent with the message.
- `test` (boolean, required): True for a test server's message: the event was simulated and nothing was sent.

### `delivered`

The receiver accepted the message for the recipient.

- `event` ("delivered", required)
- `details` (object, required)
  - `code` (integer or null, required): The SMTP status code the receiver answered; null when it gave none.
  - `response` (string or null, required): The receiver's response text.

Example:

```json
{
  "id": "9b2f4c1e-7a3d-4e5f-8c6b-1d2e3f4a5b6c",
  "attempt": 1,
  "messageId": "5f432ffd-c005-499b-aa3a-5b8a088ea20e",
  "recipient": "anna@example.com",
  "occurredAt": "2026-09-15T12:00:03.000Z",
  "serverId": "0f8a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c",
  "streamId": "3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
  "broadcastId": null,
  "tag": "invoice",
  "metadata": {
    "invoiceId": "2026-0912"
  },
  "test": false,
  "event": "delivered",
  "details": {
    "code": 250,
    "response": "2.0.0 OK"
  }
}
```

### `bounced`

The receiver refused the message for good, or Sendora gave up; the recipient is suppressed when `hard`.

- `event` ("bounced", required)
- `details` (object, required)
  - `code` (integer or null, required): The SMTP status code the receiver answered; null when it gave none.
  - `response` (string or null, required): The receiver's response text.
  - `classification` (string or null, required): How the bounce was classified, such as InvalidRecipient.
  - `hard` (boolean, required): True when the address is now on the suppression list.
  - `expired` (boolean, required): True when the message expired after repeated deferrals rather than being refused.

Example:

```json
{
  "id": "9b2f4c1e-7a3d-4e5f-8c6b-1d2e3f4a5b6c",
  "attempt": 1,
  "messageId": "5f432ffd-c005-499b-aa3a-5b8a088ea20e",
  "recipient": "anna@example.com",
  "occurredAt": "2026-09-15T12:00:03.000Z",
  "serverId": "0f8a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c",
  "streamId": "3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
  "broadcastId": null,
  "tag": "invoice",
  "metadata": {
    "invoiceId": "2026-0912"
  },
  "test": false,
  "event": "bounced",
  "details": {
    "code": 550,
    "response": "5.1.1 The email account that you tried to reach does not exist.",
    "classification": "InvalidRecipient",
    "hard": true,
    "expired": false
  }
}
```

### `deferred`

The receiver asked Sendora to try later; several may follow.

- `event` ("deferred", required)
- `details` (object, required)
  - `code` (integer or null, required): The SMTP status code the receiver answered; null when it gave none.
  - `response` (string or null, required): The receiver's response text.

Example:

```json
{
  "id": "9b2f4c1e-7a3d-4e5f-8c6b-1d2e3f4a5b6c",
  "attempt": 1,
  "messageId": "5f432ffd-c005-499b-aa3a-5b8a088ea20e",
  "recipient": "anna@example.com",
  "occurredAt": "2026-09-15T12:00:03.000Z",
  "serverId": "0f8a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c",
  "streamId": "3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
  "broadcastId": null,
  "tag": "invoice",
  "metadata": {
    "invoiceId": "2026-0912"
  },
  "test": false,
  "event": "deferred",
  "details": {
    "code": 451,
    "response": "4.7.1 Greylisted, try again later"
  }
}
```

### `spam_complaint`

The recipient reported the message as spam; only Sendora support lifts that suppression.

- `event` ("spam_complaint", required)
- `details` (object, required)
  - `feedbackType` (string or null, required): The feedback type the receiver reported, such as abuse.

Example:

```json
{
  "id": "9b2f4c1e-7a3d-4e5f-8c6b-1d2e3f4a5b6c",
  "attempt": 1,
  "messageId": "5f432ffd-c005-499b-aa3a-5b8a088ea20e",
  "recipient": "anna@example.com",
  "occurredAt": "2026-09-15T12:00:03.000Z",
  "serverId": "0f8a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c",
  "streamId": "3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
  "broadcastId": null,
  "tag": "invoice",
  "metadata": {
    "invoiceId": "2026-0912"
  },
  "test": false,
  "event": "spam_complaint",
  "details": {
    "feedbackType": "abuse"
  }
}
```

### `unsubscribed`

The recipient unsubscribed through the link or their mail client's one-click button; only Sendora support lifts that suppression.

- `event` ("unsubscribed", required)
- `details` (object, required)
  - `source` ("link" | "one_click", required): `link` for the page's button, `one_click` for a mail client's request.

Example:

```json
{
  "id": "9b2f4c1e-7a3d-4e5f-8c6b-1d2e3f4a5b6c",
  "attempt": 1,
  "messageId": "5f432ffd-c005-499b-aa3a-5b8a088ea20e",
  "recipient": "anna@example.com",
  "occurredAt": "2026-09-15T12:00:03.000Z",
  "serverId": "0f8a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c",
  "streamId": "3d1a2b4c-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
  "broadcastId": "2c4e6a8b-0d1f-4a3b-8c5d-7e9f1a3b5c7d",
  "tag": "newsletter",
  "metadata": {},
  "test": false,
  "event": "unsubscribed",
  "details": {
    "source": "one_click"
  }
}
```

## The monthly cap

Every event in this group carries these fields:

- `id` (string, required): The delivery's id. A delivery may arrive more than once, so key your handling on it.
- `attempt` (integer, required): The attempt number, from 1.
- `scope` ("account" | "server" | "test", required): Whose cap it is: `account` for the account's, `server` for a server's own, `test` for the cap the account's test servers share.
- `serverId` (string or null, required): The server whose cap it is; null for the account's or the test servers' cap.
- `cap` (integer, required): Emails the scope may send in the month.
- `used` (integer, required): Emails sent in the month so far.
- `periodStart` (string (date-time), required): The month the counts are for: its first moment, in UTC.
- `periodEnd` (string (date-time), required): The first moment of the next month, in UTC.
- `occurredAt` (string (date-time), required): When it happened.

### `cap_warning`

80 percent of the monthly cap is used.

- `event` ("cap_warning", required)

Example:

```json
{
  "id": "4d6f8a0b-2c4e-4f6a-8b0c-2d4e6f8a0b1c",
  "attempt": 1,
  "scope": "account",
  "serverId": null,
  "cap": 50000,
  "periodStart": "2026-09-01T00:00:00.000Z",
  "periodEnd": "2026-10-01T00:00:00.000Z",
  "used": 40000,
  "occurredAt": "2026-09-24T09:30:00.000Z",
  "event": "cap_warning"
}
```

### `cap_reached`

A send was refused because the monthly cap is used up.

- `event` ("cap_reached", required)

Example:

```json
{
  "id": "4d6f8a0b-2c4e-4f6a-8b0c-2d4e6f8a0b1c",
  "attempt": 1,
  "scope": "account",
  "serverId": null,
  "cap": 50000,
  "periodStart": "2026-09-01T00:00:00.000Z",
  "periodEnd": "2026-10-01T00:00:00.000Z",
  "used": 50000,
  "occurredAt": "2026-09-29T16:05:00.000Z",
  "event": "cap_reached"
}
```

## Received mail

### `inbound`

A message was received on an inbound stream: the reference, or the reference and the message itself, as the webhook asks.

Both forms carry these fields:

- `id` (string, required): The delivery's id. A delivery may arrive more than once, so key your handling on it.
- `attempt` (integer, required): The attempt number, from 1.
- `event` ("inbound", required)
- `inboundMessageId` (string, required): The received message; GET /v1/inbound/{id} reads it.
- `serverId` (string, required): The server whose stream received it.
- `streamId` (string, required): The inbound stream that received it.
- `receivedAt` (string (date-time), required): When the message was accepted from the sending server.
- `envelopeRecipient` (string, required): The address of yours the message was sent to.
- `mailboxHash` (string or null, required): The text after `+` in the local part of that address, when the sender used one.
- `sizeBytes` (integer, required): The size of the message as received, in bytes.
- `attachmentCount` (integer, required)
- `hasText` (boolean, required)
- `hasHtml` (boolean, required)
- `authentication` (object, required): What Sendora found when it checked the message's authentication; `unchecked` until the checks have run.
  - `spf` ("pass" | "fail" | "softfail" | "neutral" | "none" | "temperror" | "permerror" | "unchecked", required): SPF for the address in MAIL FROM.
  - `spfHelo` ("pass" | "fail" | "softfail" | "neutral" | "none" | "temperror" | "permerror" | "unchecked", required): SPF for the name the sending server gave in HELO.
  - `dkim` ("pass" | "fail" | "none" | "temperror" | "permerror" | "unchecked", required)
  - `dmarc` ("pass" | "fail" | "none" | "temperror" | "permerror" | "unchecked", required)
  - `dmarcPolicy` ("none" | "quarantine" | "reject" or null, required): What the sender's domain asks for; told only when DMARC failed.
  - `arc` ("none" | "pass" | "fail" | "unchecked", required)
  - `checkedAt` (string (date-time) or null, required): When the checks ran; null until they have.
- `parseIssue` (string or null, required): What the parser met; null when the message parsed clean. A hard issue empties the parsed parts, and the raw message stays: `no_headers`, `header_block_too_large`, `too_many_headers`, `too_many_parts`, `nesting_too_deep`, `missing_boundary`, `decoded_too_large`, `parse_timeout`, `parser_error`. A soft issue keeps them and says what was changed or dropped: `truncated_multipart`, `too_many_attachments`, `unknown_transfer_encoding`, `undecodable_container`, `filename_sanitised`, `control_chars_stripped`, `multiple_from`, `duplicate_header`, `malformed_header_dropped`, `address_list_truncated`, `header_value_truncated`. A message with several names the hard one, or the first soft one in this order.

Form 1: The reference to the received message, for a webhook with `inboundContent` reference.

Example:

```json
{
  "id": "6e8a0c2e-4a6c-4e8a-8c0e-2a4c6e8a0c2e",
  "attempt": 1,
  "event": "inbound",
  "inboundMessageId": "1b3d5f7a-9c1e-4b3d-8f7a-9c1e3b5d7f9a",
  "serverId": "0f8a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c",
  "streamId": "8a0c2e4a-6c8e-4a0c-8e4a-6c8e0a2c4e6a",
  "receivedAt": "2026-09-15T12:00:00.000Z",
  "envelopeRecipient": "support@example.se",
  "mailboxHash": null,
  "sizeBytes": 18432,
  "attachmentCount": 1,
  "hasText": true,
  "hasHtml": true,
  "authentication": {
    "spf": "pass",
    "spfHelo": "pass",
    "dkim": "pass",
    "dmarc": "pass",
    "dmarcPolicy": null,
    "arc": "none",
    "checkedAt": "2026-09-15T12:00:00.000Z"
  },
  "parseIssue": null
}
```

Form 2: The reference and the message itself, for a webhook with `inboundContent` full.

- `envelope` (object or null, required): The envelope sender; null for a bounce.
  - `sender` (string or null, required)
- `from` (object or null, required)
  - `address` (string, required)
  - `name` (string or null, required)
- `replyTo` (array of object, required)
  - `address` (string, required)
  - `name` (string or null, required)
- `to` (array of object, required): Up to 100 entries; `toCount` is the whole number.
  - `address` (string, required)
  - `name` (string or null, required)
- `toCount` (integer, required)
- `cc` (array of object, required): Up to 100 entries; `ccCount` is the whole number.
  - `address` (string, required)
  - `name` (string or null, required)
- `ccCount` (integer, required)
- `subject` (string or null, required)
- `date` (string (date-time) or null, required): The sender's Date header, when it was a real moment.
- `messageIdHeader` (string or null, required)
- `inReplyTo` (string or null, required)
- `references` (array of string, required)
- `headers` (array of object, required)
  - `name` (string, required)
  - `value` (string, required)
- `text` (string or null, required): The plain-text body; null when there is none, or over 1 MiB.
- `html` (string or null, required): The HTML body; null when there is none, or over 2 MiB.
- `attachments` (array of object, required): Each attachment, without its bytes.
  - `position` (integer, required): Its place among the message's attachments, from 0.
  - `name` (string, required)
  - `contentType` (string, required)
  - `contentId` (string or null, required): The Content-ID an HTML body refers to with `cid:`, when it has one.
  - `size` (integer, required)
  - `inline` (boolean, required): True for a part shown in the body rather than offered as a file.
- `contentOmitted` ("too_large" | "expired" or null, required): `too_large` when text or HTML was left out for size; `expired` when the content window has passed and the reference is all that remains.

Example:

```json
{
  "id": "6e8a0c2e-4a6c-4e8a-8c0e-2a4c6e8a0c2e",
  "attempt": 1,
  "event": "inbound",
  "inboundMessageId": "1b3d5f7a-9c1e-4b3d-8f7a-9c1e3b5d7f9a",
  "serverId": "0f8a1b2c-3d4e-4f5a-8b6c-7d8e9f0a1b2c",
  "streamId": "8a0c2e4a-6c8e-4a0c-8e4a-6c8e0a2c4e6a",
  "receivedAt": "2026-09-15T12:00:00.000Z",
  "envelopeRecipient": "support@example.se",
  "mailboxHash": null,
  "sizeBytes": 18432,
  "attachmentCount": 1,
  "hasText": true,
  "hasHtml": true,
  "authentication": {
    "spf": "pass",
    "spfHelo": "pass",
    "dkim": "pass",
    "dmarc": "pass",
    "dmarcPolicy": null,
    "arc": "none",
    "checkedAt": "2026-09-15T12:00:00.000Z"
  },
  "parseIssue": null,
  "envelope": {
    "sender": "anna@example.com"
  },
  "from": {
    "address": "anna@example.com",
    "name": "Anna Andersson"
  },
  "replyTo": [],
  "to": [
    {
      "address": "support@example.se",
      "name": null
    }
  ],
  "toCount": 1,
  "cc": [],
  "ccCount": 0,
  "subject": "A question about the invoice",
  "date": "2026-09-15T11:59:58.000Z",
  "messageIdHeader": "<20260915115958.a1b2@example.com>",
  "inReplyTo": null,
  "references": [],
  "headers": [
    {
      "name": "From",
      "value": "Anna Andersson <anna@example.com>"
    },
    {
      "name": "To",
      "value": "support@example.se"
    },
    {
      "name": "Subject",
      "value": "A question about the invoice"
    }
  ],
  "text": "Hi! I have a question about the invoice for September.",
  "html": "<p>Hi! I have a question about the invoice for September.</p>",
  "attachments": [
    {
      "position": 0,
      "name": "invoice.pdf",
      "contentType": "application/pdf",
      "contentId": null,
      "size": 15360,
      "inline": false
    }
  ],
  "contentOmitted": null
}
```
