Webhooks and signatures

Get delivery, reply, consent and suppression events pushed to your server, and verify each one is from Hermeticor.

Embed
Preview of the card
<a href="https://hermeticor.com/guides/webhooks"><img src="https://hermeticor.com/guides/_api/public/orgs/5fcd8b3c-a27c-4688-972f-de710125592f/spaces/9a3da731-ded5-4536-b84f-8c9a61420ff4/pages/6e3d95f4-5eda-45be-8362-9bd57be10bdc/social-card.svg?v=3e7ae33eec8b" alt="Webhooks and signatures" width="600" height="315"></a>

What you need first

  • An HTTPS endpoint on a public host. Private and loopback addresses are refused.

  • A key with integrations:write.

1. See the topics

curl -s "$BASE/v1/webhooks/topics" -H "Authorization: Bearer $HERMETICA_API_KEY"

Useful ones: message.status (delivery changes), message.replied, consent.changed, suppression.added, domain.*, connection.health_changed, mailbox.paused. An unknown topic is refused with 400.

2. Create the endpoint

curl -s -X POST "$BASE/v1/webhooks/endpoints" \
  -H "Authorization: Bearer $HERMETICA_API_KEY" -H "Content-Type: application/json" \
  -d '{"url":"https://hooks.example.com/hermetica","topics":["message.status","message.replied","consent.changed","suppression.added"],
       "description":"Production"}'

The answer is 201 with a secret. It is shown once. Store it. POST /v1/webhooks/endpoints/<id>/rotate-secret gives a new one.

3. Verify every request

Each request carries:

  • Hermetica-Signature: t=<unix seconds>,v1=<hex>

  • Hermetica-Event-Id and Hermetica-Topic

v1 is HMAC-SHA256 of "<t>.<raw body>" with your secret. Check it against the raw body, before you parse JSON:

import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(secret, header, rawBody) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const age = Math.abs(Date.now() / 1000 - Number(parts.t));
  if (!parts.t || !parts.v1 || age > 300) return false;          // reject old or replayed requests
  const want = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
  return want.length === parts.v1.length && timingSafeEqual(Buffer.from(want), Buffer.from(parts.v1));
}

Answer with a 2xx quickly and do the work afterwards. Use Hermetica-Event-Id to ignore an event you already handled.

4. Retries

A delivery that fails or times out (10 seconds) is retried with backoff: 30 seconds, doubling up to 6 hours between tries, for 24 hours. After that it is marked failed. You can replay any delivery:

curl -s "$BASE/v1/webhooks/endpoints/<endpointId>/deliveries" -H "Authorization: Bearer $HERMETICA_API_KEY"
curl -s -X POST "$BASE/v1/webhooks/deliveries/<deliveryId>/replay" -H "Authorization: Bearer $HERMETICA_API_KEY"

5. Test it

curl -s -X POST "$BASE/v1/webhooks/endpoints/<endpointId>/test" -H "Authorization: Bearer $HERMETICA_API_KEY"
curl -s -X POST "$BASE/v1/webhooks/endpoints/<endpointId>/test-topic" \
  -H "Authorization: Bearer $HERMETICA_API_KEY" -H "Content-Type: application/json" \
  -d '{"topic":"message.status"}'

Filters

Only get email events:

curl -s -X POST "$BASE/v1/webhooks/endpoints/<endpointId>/filters" \
  -H "Authorization: Bearer $HERMETICA_API_KEY" -H "Content-Type: application/json" \
  -d '{"path":"channel","op":"eq","value":"email"}'

Every filter must match. Topics without that field are then dropped.

History

GET /v1/webhooks/journal?limit=50 lists every event sent. GET /v1/webhooks/summary shows success rates per endpoint.

Updated