API Docs / Webhooks
Webhooks
Webhooks deliver real-time HTTP POST notifications to your server when events happen in Kerabie Mail. Available on the Premium plan. Configure endpoints under Webhooks in the sidebar.
Hosting partner? The partner_* events below use this exact same signing and retry mechanism — see the Hosting Partner API docs for the full event list and create endpoints from Partner Dashboard → Developer.
Overview
Endpoints
Add up to 10 HTTPS endpoints per account. Each endpoint subscribes to a subset of events.
Delivery
Payloads are delivered within seconds of the event. Retries run for up to 8 minutes on failure.
Security
Every request is signed with HMAC-SHA256. Verify the signature before processing.
Managing endpoints
Register and manage endpoints from Webhooks in the sidebar, or programmatically — these management calls use your browser/mobile session, not an API key (an API key with the webhooks scope can still be used for send/mailbox calls elsewhere, but managing webhook endpoints themselves isn't exposed to API keys today).
/webhookssecret exactly once — GET /webhooks never returns it again.{
"url": "https://your-app.com/webhooks/kerabie",
"events": ["email.received", "email.bounced"],
"allowed_ips": null // optional — restricts which destination IP this URL may resolve to
}/webhooks/webhooks/{id}is_active (pauses delivery without deleting) or replace allowed_ips. url/events can't be changed — delete and re-create instead./webhooks/{id}/webhooks/{id}/rotate-secret/webhooks/{id}/testemail.received payload immediately, bypassing the queue./webhooks/{id}/deliveries?limit=50pending/delivered/dead), attempts, response_status, created_at. Also shown under Webhooks → Deliveries in the dashboard./webhooks/{id}/deliveries/{delivery_id}/retrydead or failed delivery for one fresh attempt — resets its attempt count. 400 if the delivery isn't currently dead/failed./webhooks/{id}/deliveries/{delivery_id}/replayretry, which only works on dead/failed deliveries and overwrites that same row, this creates a brand-new delivery (with its own id, pointing back at the original via replayed_from_id) so the original attempt's history is never lost. Useful for backfilling a newly added endpoint, or just re-confirming your receiving side handles an event correctly.Events
| Event | Triggered when |
|---|---|
| email.received | An email arrives in a mailbox (includes threading fields) |
| email.sent | An email is successfully sent |
| email.failed | Sending fails permanently (all retries exhausted) |
| email.opened | A tracked email is opened by the recipient |
| email.clicked | A tracked link inside a sent email is clicked (fires per click, not deduped) |
| email.bounced | A sent email hard-bounces |
| email.spam_reported | Recipient marks the email as spam |
| email.forwarded | A forwarding rule delivers an email to another address |
| mailbox.created | A new mailbox is provisioned |
| mailbox.deleted | A mailbox is deleted |
| mailbox.quota_reached | Storage exceeds 90% of the mailbox quota |
| domain.verified | A custom domain passes DNS verification |
| domain.verification_failed | DNS verification fails after 3 attempts |
| partner_mailbox.created | A hosting partner provisions a client mailbox |
| partner_mailbox.deleted | A hosting partner deletes a client mailbox |
| partner_mailbox.renewed | A mailbox's prepaid term is renewed |
| partner_mailbox.expiring_soon | A paid mailbox's term ends in ~7 days |
| partner_domain.added | A hosting partner claims a client domain |
| partner_domain.verified | A client domain passes DNS verification |
| partner_domain.verification_failed | A client domain fails DNS verification |
| partner_pool.purchased | A mailbox-slot pool purchase completes |
| partner_addon.purchased | A domain-slot or storage add-on purchase completes |
| partner_trial.expiring_soon | A hosting partner's trial ends in ~7 days |
Payload structure
Every webhook POST has this envelope:
{
"event": "email.received",
"timestamp": "2026-05-17T10:00:00Z",
"webhook_id": "wh_1042",
"data": { ... event-specific fields ... }
}email.received data
{
"message_id": "<abc123@kerabie.email>",
"mailbox": "john@yourdomain.com",
"from": "sender@example.com",
"subject": "Re: Project update",
"received_at": "2026-05-17T10:00:00Z",
// Threading — use these to group conversations
"thread_id": "a3f1c29d8e7b4012", // stable 16-char SHA-256 prefix
"in_reply_to": "<prev@example.com>", // Message-ID this replies to (null if new thread)
"references": "<root@x.com> <prev@x.com>", // full thread chain
"is_reply": true,
// Content preview — use for AI auto-respond without a separate inbox call
"preview": "Hey John, just following up on...",
"has_attachments": false,
"size_bytes": 4200
}email.opened data
{
"message_id": "<abc123@kerabie.email>",
"opened_at": "2026-05-17T11:30:00Z",
"location_country": "NG",
"client": "Apple Mail / macOS"
}email.clicked data
Fires once per click — a message with several tracked links clicked fires this once per click, not once per message.
{
"message_id": "<abc123@kerabie.email>",
"url": "https://yourdomain.com/receipts/4421",
"clicked_at": "2026-05-17T11:32:00Z"
}domain.verified data
{
"domain": "yourdomain.com",
"verified_at": "2026-05-17T10:05:00Z",
"checks": { "mx": true, "spf": true, "dkim": true, "dmarc": true }
}Conversation threading
The thread_id field is a stable 16-character identifier derived from the email's References and In-Reply-To headers (or the normalised subject for new threads). Use it to group webhook events into conversations in your database.
CRM example — group emails by thread
// Receive webhook
const { event, data } = req.body
if (event === 'email.received') {
const thread = await db.threads.upsert({
where: { id: data.thread_id },
create: { id: data.thread_id, subject: data.subject, mailbox: data.mailbox },
update: { last_received_at: data.received_at },
})
await db.messages.create({
data: {
thread_id: thread.id,
message_id: data.message_id,
from: data.from,
preview: data.preview,
received_at: data.received_at,
is_reply: data.is_reply,
}
})
}AI auto-respond example
if (event === 'email.received' && data.is_reply) {
// Fetch thread history from your DB for context
const history = await db.messages
.findMany({ where: { thread_id: data.thread_id }, orderBy: { received_at: 'asc' } })
// Ask AI to draft a reply using the preview + history
const draft = await generateReply({ preview: data.preview, history })
// Send via Kerabie API
await fetch('https://api.kerabie.email/mail/send', {
method: 'POST',
headers: { Authorization: `Bearer ${API_KEY}` },
body: JSON.stringify({
from_email: data.mailbox,
to: [data.from],
subject: 'Re: ' + data.subject,
body_html: draft,
})
})
}Signature verification
Every webhook request includes an X-Kerabie-Signature header. Always verify this before processing the payload to prevent spoofed events.
The signature format is t={unix_timestamp},v1={hmac_hex}. The signed payload is {timestamp}.{json_body} (compact, no spaces). This prevents replay attacks — reject requests where t is more than 300 seconds old.
Node.js / TypeScript
import crypto from 'crypto'
function verifyWebhook(
rawBody: Buffer,
signatureHeader: string,
secret: string
): boolean {
const parts = Object.fromEntries(
signatureHeader.split(',').map(p => p.split('='))
) as Record<string, string>
const timestamp = parts['t']
const receivedSig = parts['v1']
// Reject stale requests (> 5 minutes)
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false
const sigPayload = `${timestamp}.${rawBody.toString()}`
const expected = crypto
.createHmac('sha256', secret)
.update(sigPayload)
.digest('hex')
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(receivedSig)
)
}Python
import hmac, hashlib, time
def verify_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool:
parts = dict(p.split('=', 1) for p in signature_header.split(','))
timestamp = parts.get('t', '')
received = parts.get('v1', '')
if abs(time.time() - float(timestamp)) > 300:
return False # Stale
sig_payload = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), sig_payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received)Retry schedule
If your endpoint returns a non-2xx response or times out (10s), Kerabie retries with exponential backoff. After 5 failures the delivery is marked dead and no further retries occur. Dead deliveries appear in the Webhooks page's delivery log.
| Attempt | Delay after previous failure | Cumulative time |
|---|---|---|
| 1 | Immediate | 0s |
| 2 | 1 minute | 1 min |
| 3 | 2 minutes | 3 min |
| 4 | 4 minutes | 7 min |
| 5 | 8 minutes | 15 min |
| Dead | — | 15 min+ |
Your endpoint must respond within 10 seconds. For slow processing, respond immediately with 200 and handle the payload asynchronously.
Integration examples
email.received→SlackPost new email to #sales channel with sender, subject, and preview
email.received→CRMAttach email to contact record; group by thread_id for conversation view
email.received→AI auto-responderUse preview + thread history to draft and send a reply via the API
email.received→Notion / AirtableLog incoming leads to a database row
email.opened→Mobile push"John opened your email" — close the feedback loop on important messages
email.bounced→Mailing list toolRemove bounced addresses from future campaigns automatically
domain.verified→Onboarding flowTrigger next onboarding step once DNS is confirmed
mailbox.quota_reached→Alert systemNotify admin before the mailbox stops receiving email