>_ emailblackhole

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

Which URLs are allowed

Anyone can make this server send a request, so destinations are restricted:

To receive webhooks on your own machine while developing, expose it through a tunnel with a public https address.