Webhooks
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
| Type | Sent when |
|---|---|
email.sent | Kula sent the email; delivery to the inbox comes next |
email.delivered | The receiving server accepted the email |
email.bounced | The email bounced. bounce_type tells whether it is permanent. Bounces explained |
email.complained | The recipient marked the email as spam |
email.opened | First open by a person (privacy proxies and scanners are ignored) |
email.clicked | First click by a person, with the url |
contact.subscribed | A contact joined a list (sign-up form, confirmed double opt-in, API, dashboard). Imports don't send it |
contact.unsubscribed | A 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"
}
}
| Field | Present |
|---|---|
message_id | Always: the id returned by POST /v1/emails |
to, subject | Always |
campaign_id, flow_id, template_id | When the email came from a campaign, a flow or a template |
tags | Always: the tags you set when sending (empty object otherwise) |
bounce_type | email.bounced: HARD (the address does not exist), SOFT (temporary, such as a full inbox) or BLOCKED (refused by the receiving server) |
detail | email.bounced: the server's answer |
url | email.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"
}
}
| Field | Values |
|---|---|
list_id | The list joined or left. null on contact.unsubscribed when the contact left every list |
source | contact.subscribed: FORM, API, DASHBOARD or MCP |
reason | contact.unsubscribed: USER (unsubscribe link, one-click button or preferences page), API, DASHBOARD, BOUNCE or COMPLAINT |
Headers
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Mailler-Webhooks/1.0 |
Mailler-Event | The event type, such as email.bounced |
Mailler-Delivery-Id | Id of this delivery, the same on every retry |
Mailler-Signature | t=<unix seconds>,v1=<signature>, see below |
Verify the signature
Check the signature to make sure a request comes from Kula:
- Read the raw request body, before any JSON parsing.
- Split
Mailler-Signatureon,to gett(a timestamp) andv1(the signature). - 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. - Compare it to
v1in 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)
})
import hashlib, hmac, json, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["KULA_WEBHOOK_SECRET"] # whsec_...
@app.post("/webhooks/kula")
def kula_webhook():
header = request.headers.get("Mailler-Signature", "")
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
timestamp = int(parts.get("t", "0"))
body = request.get_data(as_text=True)
expected = hmac.new(SECRET.encode(), f"{timestamp}.{body}".encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(parts.get("v1", ""), expected):
abort(400)
if abs(time.time() - timestamp) > 300:
abort(400)
event = json.loads(body)
# Queue the work, then answer fast
return "", 200
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(orMailler-Delivery-Id). - Don't rely on order.
email.openedcan arrive beforeemail.delivered. Usecreated_atwhen 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.
https URL, 2,000 characters at most. Private and local addresses are refused.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"]
}'
{
"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.
| Status | Code | When |
|---|---|---|
| 400 | invalid_url | Not https, or a private address |
| 400 | bad_request | Unknown event type, or 20 endpoints already |
Get, update and delete an endpoint
GET /v1/webhooks/{id}returns the endpoint.PATCH /v1/webhooks/{id}changesurl,description,eventTypesorenabled. Fields left out stay as they are. Enabling again resets the failure streak.DELETE /v1/webhooks/{id}answers204.