BitelioBitelio

Webhooks

Push Bitelio events to your own endpoints in real time using workflow Webhook steps

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 containing a Webhook step that pushes the event data to an endpoint you own.

Looking for signed, retried delivery?

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, registered under Settings → Webhooks.

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

EventDescription
email.sentAn email was successfully sent
email.deliveryAn email was delivered to the recipient
email.openA contact opened an email for the first time
email.clickA contact clicked a link in an email for the first time
email.bounceAn email bounced (hard or soft bounce)
email.complaintA contact marked an email as spam
email.receivedAn email was received at your verified domain (requires inbound email setup)

Contact events

EventDescription
contact.subscribedA contact's subscription status changed to subscribed
contact.unsubscribedA contact's subscription status changed to unsubscribed

Segment events

EventDescription
segment.<name>.entryA contact entered a segment
segment.<name>.exitA contact exited a segment

Segment event names

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.

Setting up a webhook

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.

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.
{
  "Authorization": "Bearer your-secret-token"
}
  • Body (optional): A custom request body. Leave it empty and Bitelio sends the default payload documented below; supply one and it fully replaces that default, JSON-encoded before sending.

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:

VariableValue
{{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

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

Headers

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

Body

{
  "email": "{{email}}",
  "plan": "{{plan}}",
  "referrer": "{{event.referrer}}"
}

Enable the workflow

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

Webhook payload

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

{
  "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:

Prop

Type

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.

{
  "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"
}

Prop

Type

{
  "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"
}

Prop

Type

{
  "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
}

Prop

Type

{
  "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
}

Prop

Type

Permanent bounce:

{
  "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:

{
  "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
}

Prop

Type

Bounce rate impact

Only Permanent bounces count toward your project's bounce rate and trigger automatic contact unsubscription. Transient bounces are tracked for visibility only.

{
  "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"
}

Prop

Type

Fires when mail arrives at your verified domain — setup is covered in Receiving Emails.

{
  "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
}

Prop

Type

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:

{
  "reason": "bounce"
}

Prop

Type

Segment events

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

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

Prop

Type

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:
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"
      }
    ]
  }
}
  1. Store the email ID (ac32f08e-c6b9-45d3-9824-a73dff1e3bbf) in your database

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

{
  "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:

    // 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