Webhooks
Every received message can be sent, as JSON, to a URL you choose. Webhooks are available to everyone, free sessions and permanent inboxes alike. A webhook belongs to a session and ends with it.
Register one
In the sandbox, use the webhook panel under your credentials. Or call the API (the secret is the one you authenticate with):
curl -X PUT -H "Authorization: Bearer YOUR_SECRET" -H "Content-Type: application/json" \
-d '{"url":"https://example.com/hooks/email"}' \
https://emailblackhole.io/api/sessions/YOUR_ID/webhook
The answer contains the signing secret (whsec_...). It is shown once: copy it now. Registering again issues a new secret and the old one stops working at once.
| Call | Purpose |
|---|---|
PUT /api/sessions/{id}/webhook |
Set or replace the webhook. Body: {"url": "https://..."} |
GET /api/sessions/{id}/webhook |
Show the webhook (never the secret) |
DELETE /api/sessions/{id}/webhook |
Remove it |
POST /api/sessions/{id}/webhook/test |
Send a signed ping to your URL |
GET /api/sessions/{id}/webhook/deliveries |
The last 50 delivery attempts, newest first |
What you receive
POST <your url> with Content-Type: application/json and these headers:
| Header | Meaning |
|---|---|
X-EBH-Event |
message.received or ping |
X-EBH-Delivery |
Unique per event, identical across retries. Use it to ignore duplicates. |
X-EBH-Attempt |
1 for the first try, 2 to 4 for retries |
X-EBH-Signature |
t=<unix seconds>,v1=<hex> |
User-Agent |
EmailBlackhole-Webhook/<version> |
{
"event": "message.received",
"delivery": "5f0c...",
"sessionId": "k3x9w2m8q1",
"createdAt": "2026-10-21T12:00:00Z",
"message": {
"id": "k3x9w2m8q1-1", "seq": 1, "subject": "Verify your account",
"from": [{"name": "Acme", "address": "app@acme.test"}],
"text": "...", "html": "...", "links": ["https://acme.test/verify?t=1"], "codes": ["482913"],
"attachments": [{"filename": "invoice.pdf", "contentType": "application/pdf", "size": 1234}],
"auth": { "spf": {}, "dkim": [], "dmarc": {} }
}
}
message is the same object the REST API returns, minus the raw source (fetch that with GET /api/sessions/{id}/messages/{mid}/raw if you need it). text and html are cut at 32 KB each, with textTruncated or htmlTruncated set when that happens. Attachments are listed, never included. A ping has no message.
Verify the signature
The signature is HMAC-SHA256(secret, "<t>." + <raw request body>), in hex. Check it against the raw bytes before you parse the JSON, compare in constant time, and reject timestamps more than 5 minutes old (this stops replays).
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verify(secret, header, rawBody, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSec) return false
const expected = createHmac('sha256', secret).update(parts.t + '.' + rawBody).digest('hex')
const a = Buffer.from(expected), b = Buffer.from(parts.v1 ?? '')
return a.length === b.length && timingSafeEqual(a, b)
}
import hmac, hashlib, time
def verify(secret: str, header: str, raw_body: bytes, tolerance=300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
if abs(time.time() - int(parts["t"])) > tolerance:
return False
expected = hmac.new(secret.encode(), parts["t"].encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
A known answer to test your code against: secret whsec_test, t = 1700000000, body {"a":1} gives 38877139021993b830af32feea6e18a8da83eb2f6e49ee50bd9e4cf4ca4d3789.
How deliveries behave
- Any
2xxis success. Answer quickly (the timeout is 10 seconds) and do your work afterwards. - Retried (after 2, 10 and 30 seconds): network errors, timeouts,
408,429and5xx. - Not retried: other
4xx(retrying cannot fix a rejected signature or a missing route), redirects (3xx, which are never followed: register the final URL), and blocked destinations. - Messages are delivered in the order they arrived, one at a time per session. If your endpoint is down while many messages arrive, the oldest waiting events are dropped (a queue of 50). Receiving mail is never slowed down by a slow endpoint.
Which URLs are allowed
Anyone can make this server send a request, so destinations are restricted:
httpsonly, port 443 or 8443, and no credentials in the URL.- The host must be a public internet name or address. Loopback, private, link-local (including cloud metadata addresses), carrier-grade NAT, multicast and reserved ranges are refused, and so are
localhost,*.internal,*.localand single-word names. - The check is repeated when the connection is made, after the name is resolved, so a name that turns hostile after you registered it is stopped too. No proxy is used.
- Limits: 30 deliveries a minute to any one host, 20 registrations per session per hour, 6 test pings a minute.
To receive webhooks on your own machine while developing, expose it through a tunnel with a public https address.