# Webhooks (/guides/webhooks)

import {Tab, Tabs} from 'fumadocs-ui/components/tabs';
import {TypeTable} from 'fumadocs-ui/components/type-table';

When something happens in Bitelio — an email bounces, a spam complaint arrives, a custom event fires — you can have an HTTP request hit your application in real time. The mechanism is a [workflow](/concepts/workflows) containing a **Webhook** step that pushes the event data to an endpoint you own.

<Callout title="Looking for signed, retried delivery?" type="info">
  This page covers the workflow **Webhook step**: flexible, but unsigned, with no delivery history, replay, or circuit breaker, and its retries only cover the step a retried job re-enters at (see "Receiving webhooks safely" below). For a centrally-managed alternative — Bitelio signs every request, retries end-to-end, and degrades an endpoint that keeps failing — see [Webhooks](/webhooks), registered under **Settings → Webhooks**.
</Callout>

## How it works

Webhooks ride on top of the workflow system. The chain looks like this:

1. Some event occurs inside Bitelio (an email bounces, a contact subscribes, a custom event is tracked, ...)
2. A workflow listening for that event is triggered
3. Its **Webhook** step fires, delivering an HTTP request with the relevant data to your URL

Because any tracked event can trigger a workflow, any of them — system-generated or your own custom events — can reach you as a webhook.

## Internal events

A set of internal events is tracked by Bitelio itself and offered as workflow triggers. You can't record these through the API; only the system emits them.

### Email events

| Event             | Description                                                                                              |
| ----------------- | -------------------------------------------------------------------------------------------------------- |
| `email.sent`      | An email was successfully sent                                                                           |
| `email.delivery`  | An email was delivered to the recipient                                                                  |
| `email.open`      | A contact opened an email for the first time                                                             |
| `email.click`     | A contact clicked a link in an email for the first time                                                  |
| `email.bounce`    | An email bounced (hard or soft bounce)                                                                   |
| `email.complaint` | A contact marked an email as spam                                                                        |
| `email.received`  | An email was received at your verified domain (requires [inbound email setup](/guides/receiving-emails)) |

### Contact events

| Event                  | Description                                             |
| ---------------------- | ------------------------------------------------------- |
| `contact.subscribed`   | A contact's subscription status changed to subscribed   |
| `contact.unsubscribed` | A contact's subscription status changed to unsubscribed |

### Segment events

| Event                  | Description                 |
| ---------------------- | --------------------------- |
| `segment.<name>.entry` | A contact entered a segment |
| `segment.<name>.exit`  | A contact exited a segment  |

<Callout title="Segment event names" type="info">
  The `<name>` part is the segment's name in slugified form. A segment named "VIP Users" therefore emits
  `segment.vip-users.entry` and `segment.vip-users.exit`.
</Callout>

## Setting up a webhook

import {Step, Steps} from 'fumadocs-ui/components/steps';

<Steps>
  <Step>
    ### Create the workflow

    Head to **Workflows** in the dashboard and start a new workflow, picking the event you care about as its trigger. To be notified about bounces, for instance, set the trigger to `email.bounce`.
  </Step>

  <Step>
    ### Add a Webhook step

    Attach a **Webhook** step after the trigger and fill in its settings:

    * **URL**: Where on your server the webhook should land (e.g. `https://api.example.com/webhooks/bitelio`)
    * **Method**: The HTTP verb to use. `POST` is the default and the right choice for most situations.
    * **Headers** (optional): Extra headers for the request, given as JSON — handy for authentication.

    ```json
    {
      "Authorization": "Bearer your-secret-token"
    }
    ```

    * **Body** (optional): A custom request body. Leave it empty and Bitelio sends the [default payload](#webhook-payload) documented below; supply one and it fully replaces that default, JSON-encoded before sending.
  </Step>

  <Step>
    ### Use variables in the request (optional)

    `{{variable}}` interpolation works in the `url`, in header values, and in the `body`. The variable scope matches what `SEND_EMAIL` templates get, with one addition unique to webhooks: an `event` namespace carrying the trigger event's payload:

    | Variable                                                  | Value                                                                           |
    | --------------------------------------------------------- | ------------------------------------------------------------------------------- |
    | `{{id}}`, `{{email}}`                                     | The contact's ID and email.                                                     |
    | `{{<key>}}` (top-level)                                   | Any key from the contact's `data` JSON (e.g. `{{firstName}}`, `{{plan}}`).      |
    | `{{data.<key>}}`                                          | The same contact data, addressed via the `data` namespace.                      |
    | `{{event.<key>}}`                                         | Webhook-only. Fields from the trigger event payload (e.g. `{{event.subject}}`). |
    | `{{<key>}}` (from execution context)                      | Keys passed in as `context` when starting a `MANUAL` execution.                 |
    | `{{unsubscribeUrl}}`, `{{subscribeUrl}}`, `{{manageUrl}}` | Per-contact subscription management URLs.                                       |

    Two things are **not** templatable: the HTTP `method`, which has to be a literal verb (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`), and the URL scheme, which must be written out as `http://` or `https://` — placeholders may appear elsewhere in the URL, just not in place of the scheme.

    Example — forward a contact event to your own API, parameterised by contact data:

    **URL**

    ```text
    https://api.example.com/users/{{id}}/events
    ```

    **Headers**

    ```json
    {
      "Authorization": "Bearer your-secret-token"
    }
    ```

    **Body**

    ```json
    {
      "email": "{{email}}",
      "plan": "{{plan}}",
      "referrer": "{{event.referrer}}"
    }
    ```
  </Step>

  <Step>
    ### Enable the workflow

    Flip the finished workflow to enabled. From then on, every occurrence of the trigger event produces a webhook request.
  </Step>
</Steps>

## Webhook payload

With no custom body configured, the default payload Bitelio sends is a JSON request shaped like this:

```json
{
  "contact": {
    "email": "user@example.com",
    "subscribed": true,
    "data": {
      "name": "John",
      "plan": "pro"
    }
  },
  "workflow": {
    "id": "wf_abc123",
    "name": "Bounce Notifications"
  },
  "execution": {
    "id": "exec_xyz789",
    "startedAt": "2025-01-15T10:30:00.000Z"
  },
  "event": {
    "subject": "Welcome to Bitelio",
    "from": "hello@example.com",
    "fromName": "Bitelio Team",
    "messageId": "ses-message-id",
    "emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
    "templateId": null,
    "campaignId": "camp_abc123",
    "sourceType": "CAMPAIGN",
    "bounceType": "Permanent",
    "bouncedAt": "2025-01-15T10:30:00.000Z"
  }
}
```

Whatever data accompanied the triggering event ends up in the `event` field — so its exact shape varies with the event type.

### Event data by type

#### Email events

A common set of base fields appears on most email events:

<TypeTable
  type={{
  subject: { type: 'string', description: 'The email subject line.' },
  from: { type: 'string', description: 'The sender email address.' },
  fromName: { type: 'string', description: 'The sender display name.' },
  messageId: { type: 'string', description: 'Provider-side message identifier (useful for correlating with delivery logs).' },
  emailId: { type: 'string', description: 'The Bitelio email record ID — returned from `POST /v1/send`, used for correlating webhooks with API responses.' },
  templateId: { type: 'string', description: 'The template ID, if the email was sent using a template. Otherwise `null`.' },
  campaignId: { type: 'string', description: 'The campaign ID, if the email was part of a campaign. Otherwise `null`.' },
  sourceType: { type: 'string', description: 'How the email was triggered. One of `TRANSACTIONAL`, `CAMPAIGN`, `WORKFLOW`, or `INBOUND`.' },
}}
/>

On top of that base, each event type adds its own fields, shown per tab below. Remember that the base fields (`subject`, `from`, `fromName`, `messageId`, `emailId`, `templateId`, `campaignId`, `sourceType`) accompany every email event alongside the event-specific ones.

<Tabs items={['email.sent', 'email.delivery', 'email.open', 'email.click', 'email.bounce', 'email.complaint', 'email.received']}>
  <Tab value="email.sent">
    ```json
    {
      "subject": "Welcome to Bitelio",
      "from": "hello@example.com",
      "fromName": "Bitelio Team",
      "messageId": "ses-message-id",
      "emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
      "templateId": null,
      "campaignId": null,
      "sourceType": "TRANSACTIONAL",
      "sentAt": "2025-01-15T10:30:00.000Z"
    }
    ```

    <TypeTable
      type={{
  sentAt: { type: 'string', description: 'ISO 8601 timestamp of when the email was accepted for delivery.' },
}}
    />
  </Tab>

  <Tab value="email.delivery">
    ```json
    {
      "subject": "Welcome to Bitelio",
      "from": "hello@example.com",
      "fromName": "Bitelio Team",
      "messageId": "ses-message-id",
      "emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
      "templateId": null,
      "campaignId": "camp_abc123",
      "sourceType": "CAMPAIGN",
      "deliveredAt": "2025-01-15T10:30:05.000Z"
    }
    ```

    <TypeTable
      type={{
  deliveredAt: { type: 'string', description: 'ISO 8601 timestamp of when the email was delivered to the recipient.' },
}}
    />
  </Tab>

  <Tab value="email.open">
    ```json
    {
      "subject": "Welcome to Bitelio",
      "from": "hello@example.com",
      "fromName": "Bitelio Team",
      "messageId": "ses-message-id",
      "emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
      "templateId": null,
      "campaignId": null,
      "sourceType": "TRANSACTIONAL",
      "openedAt": "2025-01-15T11:00:00.000Z",
      "opens": 1,
      "isFirstOpen": true
    }
    ```

    <TypeTable
      type={{
  openedAt: { type: 'string', description: 'ISO 8601 timestamp of the first open.' },
  opens: { type: 'number', description: 'Total number of times this email has been opened.' },
  isFirstOpen: { type: 'boolean', description: '`true` if this is the first time the contact opened this email.' },
}}
    />
  </Tab>

  <Tab value="email.click">
    ```json
    {
      "subject": "Welcome to Bitelio",
      "from": "hello@example.com",
      "fromName": "Bitelio Team",
      "messageId": "ses-message-id",
      "emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
      "templateId": null,
      "campaignId": null,
      "sourceType": "TRANSACTIONAL",
      "link": "https://example.com/pricing",
      "clickedAt": "2025-01-15T11:05:00.000Z",
      "clicks": 1,
      "isFirstClick": true
    }
    ```

    <TypeTable
      type={{
  link: { type: 'string', description: 'The URL that was clicked.' },
  clickedAt: { type: 'string', description: 'ISO 8601 timestamp of the first click.' },
  clicks: { type: 'number', description: 'Total number of clicks on links in this email.' },
  isFirstClick: { type: 'boolean', description: '`true` if this is the first click from this contact on this email.' },
}}
    />
  </Tab>

  <Tab value="email.bounce">
    Permanent bounce:

    ```json
    {
      "subject": "Welcome to Bitelio",
      "from": "hello@example.com",
      "fromName": "Bitelio Team",
      "messageId": "ses-message-id",
      "emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
      "templateId": null,
      "campaignId": null,
      "sourceType": "TRANSACTIONAL",
      "bounceType": "Permanent",
      "bouncedAt": "2025-01-15T10:31:00.000Z"
    }
    ```

    Transient (soft) bounce:

    ```json
    {
      "subject": "Welcome to Bitelio",
      "from": "hello@example.com",
      "fromName": "Bitelio Team",
      "messageId": "ses-message-id",
      "emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
      "templateId": null,
      "campaignId": null,
      "sourceType": "TRANSACTIONAL",
      "bounceType": "Transient",
      "transientBounce": true
    }
    ```

    <TypeTable
      type={{
  bounceType: { type: 'string', description: '`Permanent` for hard bounces, `Transient` for soft bounces (mailbox full, out-of-office, greylist).' },
  bouncedAt: { type: 'string', description: 'ISO 8601 timestamp of when the bounce occurred. Only present on permanent bounces.' },
  transientBounce: { type: 'boolean', description: '`true` for soft bounces — these do not count toward your bounce rate and the contact stays subscribed.' },
}}
    />

    <Callout title="Bounce rate impact" type="warn">
      Only `Permanent` bounces count toward your project's bounce rate and trigger automatic contact unsubscription.
      `Transient` bounces are tracked for visibility only.
    </Callout>
  </Tab>

  <Tab value="email.complaint">
    ```json
    {
      "subject": "Welcome to Bitelio",
      "from": "hello@example.com",
      "fromName": "Bitelio Team",
      "messageId": "ses-message-id",
      "emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
      "templateId": null,
      "campaignId": null,
      "sourceType": "TRANSACTIONAL",
      "complainedAt": "2025-01-15T10:35:00.000Z"
    }
    ```

    <TypeTable
      type={{
  complainedAt: { type: 'string', description: 'ISO 8601 timestamp of when the complaint was received.' },
}}
    />
  </Tab>

  <Tab value="email.received">
    Fires when mail arrives at your verified domain — setup is covered in [Receiving Emails](/guides/receiving-emails).

    ```json
    {
      "messageId": "ses-message-id",
      "from": "sender@example.com",
      "fromHeader": "Jane Smith <sender@example.com>",
      "to": "support@yourdomain.com",
      "subject": "Re: Your question",
      "timestamp": "2025-01-15T10:30:00.000Z",
      "recipients": ["support@yourdomain.com"],
      "hasContent": true,
      "body": "<html><body>This is the email body content...</body></html>",
      "spamVerdict": "PASS",
      "virusVerdict": "PASS",
      "spfVerdict": "PASS",
      "dkimVerdict": "PASS",
      "dmarcVerdict": "PASS",
      "processingTimeMillis": 142
    }
    ```

    <TypeTable
      type={{
  messageId: { type: 'string', description: 'Unique identifier for the received message.' },
  from: { type: 'string', description: "The sender's email address." },
  fromHeader: { type: 'string', description: 'The full `From` header, including display name if present.' },
  to: { type: 'string', description: 'The recipient address at your verified domain.' },
  subject: { type: 'string', description: 'The email subject line.' },
  timestamp: { type: 'string', description: 'ISO 8601 timestamp when the email was received.' },
  recipients: { type: 'array of strings', description: 'All recipient addresses in the envelope.' },
  hasContent: { type: 'boolean', description: 'Whether the email body content is available.' },
  body: { type: 'string', description: 'Sanitized HTML body of the email (or plain text if no HTML available).' },
  spamVerdict: { type: 'string', description: 'Spam check result. One of `PASS`, `FAIL`, `GRAY`, or `PROCESSING_FAILED`.' },
  virusVerdict: { type: 'string', description: 'Virus check result. One of `PASS`, `FAIL`, `GRAY`, or `PROCESSING_FAILED`.' },
  spfVerdict: { type: 'string', description: 'SPF authentication result.' },
  dkimVerdict: { type: 'string', description: 'DKIM authentication result.' },
  dmarcVerdict: { type: 'string', description: 'DMARC authentication result.' },
  processingTimeMillis: { type: 'number', description: 'Time taken to process the inbound email.' },
}}
    />
  </Tab>
</Tabs>

#### Contact events

By default, `contact.subscribed` and `contact.unsubscribed` come with no event data at all — `event` is just `{}`.

There's one exception: when a bounce or complaint caused the unsubscription automatically, `event` carries a `reason`:

```json
{
  "reason": "bounce"
}
```

<TypeTable
  type={{
  reason: { type: 'string', description: 'Why the contact was unsubscribed. One of `bounce` or `complaint`. Only present when triggered automatically by a bounce or complaint.' },
}}
/>

#### Segment events

`segment.<name>.entry` and `segment.<name>.exit` both carry:

```json
{
  "segmentId": "seg_abc123",
  "segmentName": "VIP Users"
}
```

<TypeTable
  type={{
  segmentId: { type: 'string', description: 'The ID of the segment.' },
  segmentName: { type: 'string', description: 'The display name of the segment.' },
}}
/>

#### Custom events

For events you track through the API, the payload is whatever `data` object you supplied in the `track` call.

#### No event data

Events without any associated data produce an empty object `{}` in the `event` field.

## Correlating webhooks with send requests

Every email event carries an `emailId` — the same Bitelio email record ID that `POST /v1/send` hands back. That gives you a direct join between webhook events and the API calls that caused them.

**Example workflow:**

1. Send email via API:

```json
POST /v1/send
{
  "to": "user@example.com",
  "subject": "Welcome",
  "body": "Hello!"
}

Response:
{
  "success": true,
  "data": {
    "emails": [
      {
        "contact": {"id": "cnt_abc", "email": "user@example.com"},
        "email": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf"
      }
    ]
  }
}
```

2. Store the `email` ID (`ac32f08e-c6b9-45d3-9824-a73dff1e3bbf`) in your database

3. When webhook events fire (e.g., `email.open`, `email.bounce`), match them using `event.emailId`:

```json
{
  "event": {
    "emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
    "messageId": "ses-message-id",
    "openedAt": "2025-01-15T11:00:00.000Z"
  }
}
```

With this join in place, there's no reason to match on contact email plus timestamp, nor to subscribe to `email.sent` webhooks purely to capture the provider `messageId`.

## Common use cases

### Bounce and complaint monitoring

Forward bounces and complaints to your application with a workflow triggered on `email.bounce` or `email.complaint` — a straightforward way to keep your own database aligned with Bitelio's contact statuses.

Steps placed before the webhook let you refine the behavior:

* **Condition**: Restrict the webhook to hard bounces by inspecting `bounceType`
* **Delay**: Insert a short pause so related events can be processed together
* **Update Contact**: Attach metadata to the contact before the webhook goes out

### Syncing unsubscribes

A workflow triggered on `contact.unsubscribed` can alert your application every time someone opts out — the standard pattern for keeping subscription state consistent across systems.

### Custom event forwarding

Events you track yourself (say `user.signup` or `order.completed`) can be re-broadcast to other services through webhooks. In effect, Bitelio becomes an event router: one `track` call, delivery to as many endpoints as you like.

## Receiving webhooks safely

A few properties of Bitelio's webhook step matter when designing the endpoint that receives it:

* **Method**: `POST` with `Content-Type: application/json` by default; each step can override the method.
* **Timeout**: requests are cut off after **10 seconds**. If your processing takes longer, accept the request, queue the work, and answer `2xx` immediately.
* **Redirects**: at most 5 redirects are followed, and every hop is re-checked against the SSRF rules below.
* **Public URL required**: the endpoint has to be reachable from the public internet — private and internal targets (loopback, RFC 1918 ranges, etc.) are never delivered to.
* **Schemes**: `http://` and `https://` only. Prefer HTTPS.
* **Retries, but scoped to one step**: a non-2xx response or timeout throws, and the queue retries the job (3 attempts total, exponential backoff). That retry re-runs the same step the job was queued for — a Webhook step reached later in the same run, with no `DELAY` or `WAIT_FOR_EVENT` in between, gets no retry of its own; a failure there ends the attempt immediately. Either way, make your handler idempotent, and set the step's `onError` to `continue` (default `stop`) if a failure here shouldn't stop the contact's journey.
* **Verify authenticity with a shared secret**: put a secret header on the webhook step and validate it server-side:

  ```json
  // Webhook step → Headers
  {
    "Authorization": "Bearer your-shared-secret"
  }
  ```

  Every request from that step carries the secret; rotate it as you would any shared credential. This beats IP allowlisting, since egress IPs are subject to change.

## Adding conditions and delays

Webhooks being ordinary workflow steps, they compose with everything else the workflow system offers:

* A **Condition** step gates the webhook on whatever criteria you choose (e.g. only contacts on a particular plan)
* A **Wait for Event** step holds the webhook until a follow-up event arrives (e.g. see whether a bounced contact re-subscribes first)
* A **Delay** step inserts a time buffer ahead of the webhook
