API reference

Webhooks

Receive email and contact events on your server

Webhooks notify your server of email and contact events within seconds.

Create endpoints with the routes below, or in Settings › Webhooks (admins and owners), where you can also send a test event, see each delivery and retry it.

Event types

TypeSent when
email.sentKula sent the email; delivery to the inbox comes next
email.deliveredThe receiving server accepted the email
email.bouncedThe email bounced. bounce_type tells whether it is permanent. Bounces explained
email.complainedThe recipient marked the email as spam
email.openedFirst open by a person (privacy proxies and scanners are ignored)
email.clickedFirst click by a person, with the url
contact.subscribedA contact joined a list (sign-up form, confirmed double opt-in, API, dashboard). Imports don't send it
contact.unsubscribedA contact left a list, or every list

Email events cover every email of the space: transactional, campaigns and flows.

Payload

Every event has the same envelope:

{
  "id": "evt_5f0c7d2b9a8e4f1c8b3a6d9e2f1c0b7a",
  "type": "email.bounced",
  "created_at": "2026-10-17T09:30:04Z",
  "space_id": "3f1c9a52-7b0e-4d8a-9c61-2e5f8b7a4d10",
  "data": { ... }
}

Email events

{
  "id": "evt_5f0c7d2b9a8e4f1c8b3a6d9e2f1c0b7a",
  "type": "email.bounced",
  "created_at": "2026-10-17T09:30:04Z",
  "space_id": "3f1c9a52-7b0e-4d8a-9c61-2e5f8b7a4d10",
  "data": {
    "message_id": "8f6c2a1e-3b4d-4e5f-9a0b-1c2d3e4f5a6b",
    "to": "ada@example.com",
    "subject": "Reset your password",
    "template_id": "c41d7e2a-9b8f-4a6e-b3d2-5f1e0a9c8b7d",
    "tags": { "user_id": "usr_1042" },
    "bounce_type": "HARD",
    "detail": "550 5.1.1 The email account that you tried to reach does not exist"
  }
}
FieldPresent
message_idAlways: the id returned by POST /v1/emails
to, subjectAlways
campaign_id, flow_id, template_idWhen the email came from a campaign, a flow or a template
tagsAlways: the tags you set when sending (empty object otherwise)
bounce_typeemail.bounced: HARD (the address does not exist), SOFT (temporary, such as a full inbox) or BLOCKED (refused by the receiving server)
detailemail.bounced: the server's answer
urlemail.clicked: the link clicked

Contact events

{
  "id": "evt_9a1b2c3d4e5f60718293a4b5c6d7e8f9",
  "type": "contact.unsubscribed",
  "created_at": "2026-10-17T11:02:00Z",
  "space_id": "3f1c9a52-7b0e-4d8a-9c61-2e5f8b7a4d10",
  "data": {
    "contact_id": "0d9e8f7a-6b5c-4d3e-2f1a-0b9c8d7e6f5a",
    "email": "ada@example.com",
    "list_id": "5a8e2c1f-0b3d-4e6a-9f7c-2d1e8b4a6c3f",
    "reason": "USER"
  }
}
FieldValues
list_idThe list joined or left. null on contact.unsubscribed when the contact left every list
sourcecontact.subscribed: FORM, API, DASHBOARD or MCP
reasoncontact.unsubscribed: USER (unsubscribe link, one-click button or preferences page), API, DASHBOARD, BOUNCE or COMPLAINT

Headers

HeaderValue
Content-Typeapplication/json
User-AgentMailler-Webhooks/1.0
Mailler-EventThe event type, such as email.bounced
Mailler-Delivery-IdId of this delivery, the same on every retry
Mailler-Signaturet=<unix seconds>,v1=<signature>, see below

Verify the signature

Check the signature to make sure a request comes from Kula:

  1. Read the raw request body, before any JSON parsing.
  2. Split Mailler-Signature on , to get t (a timestamp) and v1 (the signature).
  3. Compute an HMAC-SHA256 of <t>.<raw body> with your endpoint secret as the key, in lowercase hexadecimal. The key is the whole secret, whsec_ prefix included.
  4. Compare it to v1 in constant time, and reject timestamps more than 5 minutes old to block replays.
import crypto from 'node:crypto'
import express from 'express'

const app = express()
const secret = process.env.KULA_WEBHOOK_SECRET // whsec_...

app.post('/webhooks/kula', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('Mailler-Signature') ?? ''
  const parts = Object.fromEntries(header.split(',').map(p => p.split('=', 2)))
  const timestamp = Number(parts.t)
  const body = req.body.toString('utf8')

  const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex')
  const valid = parts.v1?.length === expected.length
    && crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected))
  const fresh = Math.abs(Date.now() / 1000 - timestamp) <= 300

  if (!valid || !fresh) return res.sendStatus(400)

  const event = JSON.parse(body)
  // Queue the work, then answer fast
  res.sendStatus(200)
})
Frameworks that parse JSON before your handler (express.json(), for example) change the bytes and break the signature. Always verify the raw body.

Retries

A delivery succeeds when your endpoint answers any 2xx status within 10 seconds. Redirects are not followed.

Otherwise it retries after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours, then gives up. After 50 failed deliveries in a row, the endpoint is disabled and owners and admins get an email. Enable it again in the dashboard (or with PATCH and "enabled": true) once fixed; failed deliveries can be retried from the dashboard.

Best practices:

  • Answer fast. Store the event or put it in a queue, answer 200, then do the work.
  • Expect duplicates. A delivery can arrive twice. Deduplicate with the event id (or Mailler-Delivery-Id).
  • Don't rely on order. email.opened can arrive before email.delivered. Use created_at when order matters.

The endpoint object

{
  "id": "1f2e3d4c-5b6a-4987-8a7b-6c5d4e3f2a1b",
  "url": "https://api.acme.com/webhooks/kula",
  "description": "Bounces to our user database",
  "eventTypes": ["email.bounced", "email.complained", "contact.unsubscribed"],
  "enabled": true,
  "consecutiveFailures": 0,
  "disabledReason": null,
  "createdAt": "2026-10-17T09:30:00Z",
  "updatedAt": "2026-10-17T09:30:00Z"
}

List endpoints

GET /v1/webhooks

Create an endpoint

POST /v1/webhooks

A space has at most 20 endpoints.

url
string required
A public https URL, 2,000 characters at most. Private and local addresses are refused.
description
string
200 characters at most.
eventTypes
string[] required
At least one event type.
curl https://api.kulamail.com/v1/webhooks \
  -H "Authorization: Bearer ml_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://api.acme.com/webhooks/kula",
    "description": "Bounces to our user database",
    "eventTypes": ["email.bounced", "email.complained", "contact.unsubscribed"]
  }'
201 Created
{
  "endpoint": { "id": "1f2e3d4c-…", "url": "https://api.acme.com/webhooks/kula", "enabled": true, "…": "…" },
  "secret": "whsec_k3J9x…"
}

The signing secret is returned only here: store it right away. Rotate it in the dashboard.

StatusCodeWhen
400invalid_urlNot https, or a private address
400bad_requestUnknown event type, or 20 endpoints already

Get, update and delete an endpoint

  • GET /v1/webhooks/{id} returns the endpoint.
  • PATCH /v1/webhooks/{id} changes url, description, eventTypes or enabled. Fields left out stay as they are. Enabling again resets the failure streak.
  • DELETE /v1/webhooks/{id} answers 204.