Documentation

Webhooks

Event delivery, signing, and retries.

Subscribe an endpoint to any of the event types. Use an https URL: payloads carry recipient addresses. The dashboard refuses anything else, but POST /v1/webhook-endpoints also accepts http, so the choice is yours to make there. Each delivery is signed with the endpoint's secret in an X-CN-Signature header, formatted t=<unix>,v1=<hmac>, where the HMAC is SHA-256, hex-encoded, over <t>.<body>.

Verify every payload. An unverified webhook endpoint is one anyone on the internet can post to.

Destinations

Deliveries are made from SendRaven's servers, so an endpoint must be a public address on the internet. The dashboard and the API refuse a URL whose host is localhost or an internal name (.localhost, .internal, .local, .localdomain, .home.arpa, or a name with no dot), an IP address in a loopback, private, link-local, carrier-grade NAT, multicast or reserved range (including the IPv4-mapped IPv6 and integer forms of those), or a name that resolves to one. A URL with a username and password in it is refused too, and so is a port below 1024 other than 80 and 443, because those ports belong to services such as SSH and SMTP rather than webhook receivers. The API answers 422 invalid_request with the message url: must be a public http or https address, or url: port must be 80, 443 or 1024-65535 for the port; see Webhook endpoints for the full list of ranges.

The check runs again on every delivery attempt, against what the host resolves to at that moment, and the connection goes only to an address that passed. If your DNS record starts pointing somewhere private, the attempt is not sent and is recorded as failed with destination resolves to a non-public address. The port rule is checked on every attempt too. Redirects are not followed: answer the POST at the URL you registered, because a 3xx counts as a failure.

To test a receiver on your own machine, expose it through a public tunnel and register the tunnel's https URL.

Retries

The first attempt is immediate. Any response outside 2xx, or no response within ten seconds, is a failure, retried five more times: one minute, five minutes, thirty minutes, two hours and twelve hours after the event. Respond 2xx quickly and do the work afterwards; a slow handler becomes a retry. The same event can arrive more than once, so make your handler safe to run twice. Disabling or deleting the endpoint stops the retries. GET /v1/webhook-endpoints/{id}/events lists recent deliveries with their attempts and last status code.

Events

EventMeans
inboundA message arrived on one of your domains, usually a reply. Carries the stripped reply text, sender_authenticated, which is false when the From line may be forged, and automated, which is true for an out-of-office or a bounce report.
sendSES accepted the message for sending.
deliveryThe receiving server accepted it.
bounceRejected, permanently or temporarily. Permanent bounces are suppressed automatically.
complaintMarked as spam. Always suppressed.
open / clickEngagement. A click carries link, the destination. Both update contact segments. They only fire for mail from a domain whose link. tracking hostname is live, because the pixel and the link rewriting ride that hostname. They arrive a few seconds after the recipient acted (about 5 seconds for a click, 15 for an open), because each is first checked for a security scanner; see Automated clicks and opens.
rejectSES refused it before sending.
delivery_delayTemporary failure; delivery is still being retried.
rendering_failureSES could not render the message. SendRaven renders templates itself before sending, and a missing variable is refused at the API, so this does not fire for its templates.

Payloads

The body is a JSON object, and every field name in it is snake_case, the same as the REST API. Every field listed below is present on every delivery of that event: a field with no value is null, never left out. Timestamps are ISO 8601 strings in UTC. There is no wrapper around the payload: type at the top level says which event it is.

Sent-mail events

Automated clicks and opens

Security gateways at many companies open every link and image in a message within a second of it arriving, to scan it, and many do it with an ordinary browser's user agent. Each of those fetches would otherwise arrive as a click from the recipient. A person cannot click two different links in one email within two seconds, and a scanner does it every time, so SendRaven holds every tracked click and open for a few seconds and checks what surrounds it:

  • Two clicks on different links of the same message within 2 seconds are both a scanner's. The same link twice is not, because Microsoft Safe Links fetches a link a person clicked just before their browser does.
  • An open within 10 seconds of such a burst is the scanner's too.
  • A gateway that fetches images but no links cannot be told from a reader, and is counted.

A scanner's clicks and opens send no webhook, and they are left out of everything else: opened_at and clicked_at on the email, its events, the contact's last_opened_at and last_clicked_at (and so every segment rule), campaign figures, clicked links, A/B results and metrics. A person's click after the scanner has been through, even a minute later, counts normally.

The first open of a message is the only one reported, as before.

send, delivery, bounce, complaint, reject, open, click, rendering_failure and delivery_delay all carry the same fields.

FieldTypeDescription
typestringThe event type, as in the table above.
message_idstringThe id returned when the message was sent. Read it with GET /v1/emails/{id}.
provider_message_idstring | nullThe id SES assigned to the message.
broadcast_idstring | nullThe campaign the message belongs to, or null for a message sent on its own.
variantstring | nullThe A/B test variant the message carried, or null when it was not part of a test.
toarray of stringThe message's recipient addresses, without display names.
subjectstringThe message's subject.
linkstring | nullOn a click, the destination of the link that was clicked. null on every other event.
occurred_atstring (ISO 8601)When the event happened.

A bounce or complaint does not carry the bounce type or diagnostic; read the message's timeline from GET /v1/emails/{id} for those.

{
  "type": "click",
  "message_id": "3f6c1a52-8d0e-4b7a-9c21-5e4f7a9b2d10",
  "provider_message_id": "0100019a1b2c3d4e-5f6a7b8c-9d0e-4f1a-8b2c-3d4e5f6a7b8c-000000",
  "broadcast_id": null,
  "variant": null,
  "to": ["ana@example.com"],
  "subject": "Your invoice for September",
  "link": "https://example.com/billing",
  "occurred_at": "2026-09-15T12:00:03.000Z"
}

The inbound event

FieldTypeDescription
typestringAlways inbound.
idstringThe received message's id. Pass it as reply_to_message_id on POST /v1/emails to answer it.
thread_idstringThe conversation it was joined to. Read it with GET /v1/threads/{id}.
fromstringThe sender's address from the From line, lowercased, without a display name. Not proof of who sent it; see sender_authenticated.
toarray of stringThe addresses on the To line.
subjectstringThe subject, or (no subject) when there was none.
textstringThe reply with its quoted history stripped: what the sender wrote. An empty string when the message had no body. The full body is in the thread.
received_atstring (ISO 8601)The message's Date header, or the time it arrived when it had none.
spam_verdictstring | nullThe provider's spam verdict, such as PASS or FAIL.
spf_verdictstring | nullThe provider's SPF verdict, such as PASS, FAIL or GRAY.
dkim_verdictstring | nullThe provider's DKIM verdict.
dmarc_verdictstring | nullThe provider's DMARC verdict.
sender_authenticatedbooleantrue only when DMARC passed, or a DKIM signature from the From domain passed and DMARC did not fail. Branch on this, not on the verdicts.
automatedboolean | nulltrue when a machine wrote it: an out-of-office, a bounce report or another auto-reply, decided from its headers when it arrived. The event is still delivered, but the thread's awaiting_reply is left as it was and the message needs no answer. null only on a retry of an event first queued before this field existed.

A verdict is null when the receiving rule did not report it. An authenticated sender is still someone outside your system: treat text as data to read, never as instructions to follow.

{
  "type": "inbound",
  "id": "8e1f3a5c-7b9d-4e2f-a6c8-0d2e4f6a8b1c",
  "thread_id": "2c4e6a8b-0d1f-4a3c-9e5b-7d9f1b3d5e7a",
  "from": "ana@example.com",
  "to": ["support@mail.example.com"],
  "subject": "Re: Your invoice for September",
  "text": "Thanks, that is paid now.",
  "received_at": "2026-09-15T12:05:00.000Z",
  "spam_verdict": "PASS",
  "spf_verdict": "PASS",
  "dkim_verdict": "PASS",
  "dmarc_verdict": "PASS",
  "sender_authenticated": true,
  "automated": false
}

On this page