Webhooks
Signed, outbound HTTP events for mail, forms, and bookings.
Webhooks are the only push channel Kontacts has — outbound only. Register an HTTPS URL under a project's settings and Kontacts POSTs signed JSON to it whenever a subscribable event happens on that project.
No per-event filtering
There is no subscription list. Every live (non-disabled) endpoint on a
project receives every event type below — you filter in your own handler,
usually on the
X-Kontacts-Event header or the body's type
field. (apps/web/lib/customer-webhooks.ts — the fan-out query has no
per-event where clause.)
Events
email.receivedemail.sentemail.bouncedform.submittedbooking.createdbooking.cancelledwebhook.test— dashboard "Send test" ping, not a product event
There is no booking.updated or reschedule event — no code path writes one.
email.sent / email.bounced fire from Resend's own delivery lifecycle
callback, looked up by our stored provider_id, not from the moment we
queue a send.
Envelope
Every delivery is:
{
"id": "delivery-uuid",
"type": "email.received",
"created_at": "2026-09-03T20:00:00.000Z",
"data": { /* shape depends on type — see below */ }
}Mail (email.received / email.sent / email.bounced)
{
"id": "delivery-uuid",
"type": "email.received",
"created_at": "2026-09-03T20:00:00.000Z",
"data": {
"email_id": "email-uuid",
"provider_id": "resend-email-id",
"from": "ada@example.com",
"to": ["inbox@acme.com"],
"subject": "Hello",
"direction": "inbound"
}
}
This is envelope metadata only — no API keys, no signing secrets, and no
message body. to is always a one-element array. email.bounced adds a
bounce: { type, message } field; the other two mail events don't.
Forms (form.submitted)
{
"id": "delivery-uuid",
"type": "form.submitted",
"created_at": "2026-09-05T12:00:00.000Z",
"data": {
"email_id": "email-uuid",
"form_endpoint_id": "form-uuid",
"form_name": "Contact form",
"from": "ada@example.com",
"fields": {
"name": "Ada Lovelace",
"email": "ada@example.com",
"message": "Hello"
}
}
}
fields is the sanitized submission map — the same data a
form endpoint writes to the inbox, with
honeypot fields stripped. This is the payload most Zapier/Make/n8n setups
map straight into a spreadsheet row or a chat message.
Bookings (booking.created / booking.cancelled)
{
"id": "delivery-uuid",
"type": "booking.created",
"created_at": "2026-09-05T12:00:00.000Z",
"data": {
"booking_id": "booking-uuid",
"meeting_type_id": "meeting-type-uuid",
"meeting_title": "Intro call",
"starts_at": "2026-09-06T14:00:00.000Z",
"ends_at": "2026-09-06T14:30:00.000Z",
"status": "confirmed",
"invitee_email": "ada@example.com",
"invitee_name": "Ada Lovelace",
"invitee_timezone": "Europe/Paris",
"join_url": "https://meet.google.com/xxx"
}
}
booking.cancelled adds cancelled_by: "invitee" | "owner" | null.
Verifying a delivery
Every request carries:
| Header | Value |
|---|---|
X-Kontacts-Signature | v1=<hex hmac> |
X-Kontacts-Timestamp | Unix seconds |
X-Kontacts-Delivery-Id | Delivery UUID — dedupe on this for at-least-once delivery. |
X-Kontacts-Event | Same value as the body's type. |
User-Agent | Kontacts-Webhooks/1.0 |
The signature is HMAC-SHA256, hex, over ${timestamp}.${rawBody}, keyed
with the endpoint's signing secret (shown once, at creation or rotation).
Reference verifier, copied from the same module the deliverer imports:
const crypto = require("crypto");
function verify(secret, timestamp, rawBody, signatureHeader) {
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`, "utf8")
.digest("hex");
const token = (signatureHeader || "")
.split(",")
.map((p) => p.trim())
.find((p) => p.startsWith("v1="));
const given = token ? token.slice(3) : "";
return given.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}
Delivery, retries, and disable
Answer any 2xx and the delivery
is done. Anything else is retried on this backoff, after an immediate first
attempt:
1min, 5min, 15min, 1h, 4h, 12h, 24h
That's 8 attempts total (1
immediate + 7 retries) before a
delivery is marked dead. After
8 consecutive failed
deliveries, the endpoint itself is disabled and every other pending
delivery on it is marked dead too — check last delivery status on the
settings page rather than assuming silence means success.
Registration facts, cited directly
A project may have at most 5 live webhooks
(MAX_LIVE_WEBHOOKS, apps/web/lib/project-webhooks.ts). URLs must be
HTTPS, may not embed credentials or a fragment, and are rejected if they
resolve to localhost, a .internal/.local host, the cloud metadata
host, or a private/link-local IPv4 or IPv6 literal — SSRF hardening in
apps/web/lib/webhook-url.ts. Signing secrets are prefixed whsec_
(WEBHOOK_SECRET_PREFIX, apps/web/lib/project-webhooks.ts) and, like API
keys, have no expiry — only a rotate action and manual revoke.
Management is session-authed, not API-key-authed
Registering, updating, rotating, or deleting a webhook endpoint
(/api/projects/:id/webhooks*) requires a logged-in session and project
admin — not a gm_live_… or jwt_… API key. There is no REST way to
manage webhooks with the same key you send mail with; use the dashboard.
Send a test delivery from the settings page to fire
webhook.test — each click mints a fresh delivery id, and it
goes through the exact same signing and retry path as a real event.
Zapier, Make, n8n
There's no official app listing yet — this is the generic-webhook path, and it works today:
- In Zapier: create a Zap → Webhooks by Zapier → Catch Hook. Copy the hook URL.
- In Make: add a Custom webhook module, create a hook, copy the URL.
- In n8n: add a Webhook node (POST), activate the workflow, copy the production URL.
- In Kontacts: Settings → Webhooks → paste that HTTPS URL → Add webhook. Copy the signing secret now.
- Click Send test. Your Catch Hook / Custom webhook / n8n node should receive type webhook.test with a valid HMAC.
- Filter on the X-Kontacts-Event header (or body.type). For a form → Notion/Slack flow, keep form.submitted.
- Map data.fields.* (forms) or data.invitee_email / data.starts_at (bookings) into the next step.
- Optional: a Code step recomputes HMAC-SHA256(secret, `${timestamp}.${rawBody}`) and compares it to the v1= token on X-Kontacts-Signature. The timestamp is X-Kontacts-Timestamp (Unix seconds).
Registering an endpoint
Webhooks are configured under Settings → Webhooks
(/dashboard/settings/webhooks) in the dashboard — there is
no API for creating one, only for receiving what you register there.