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
| Event | Means |
|---|---|
| inbound | A 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. |
| send | SES accepted the message for sending. |
| delivery | The receiving server accepted it. |
| bounce | Rejected, permanently or temporarily. Permanent bounces are suppressed automatically. |
| complaint | Marked as spam. Always suppressed. |
| open / click | Engagement. 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. |
| reject | SES refused it before sending. |
| delivery_delay | Temporary failure; delivery is still being retried. |
| rendering_failure | SES 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.
| Field | Type | Description |
|---|---|---|
type | string | The event type, as in the table above. |
message_id | string | The id returned when the message was sent. Read it with GET /v1/emails/{id}. |
provider_message_id | string | null | The id SES assigned to the message. |
broadcast_id | string | null | The campaign the message belongs to, or null for a message sent on its own. |
variant | string | null | The A/B test variant the message carried, or null when it was not part of a test. |
to | array of string | The message's recipient addresses, without display names. |
subject | string | The message's subject. |
link | string | null | On a click, the destination of the link that was clicked. null on every other event. |
occurred_at | string (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.
The inbound event
| Field | Type | Description |
|---|---|---|
type | string | Always inbound. |
id | string | The received message's id. Pass it as reply_to_message_id on POST /v1/emails to answer it. |
thread_id | string | The conversation it was joined to. Read it with GET /v1/threads/{id}. |
from | string | The sender's address from the From line, lowercased, without a display name. Not proof of who sent it; see sender_authenticated. |
to | array of string | The addresses on the To line. |
subject | string | The subject, or (no subject) when there was none. |
text | string | The 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_at | string (ISO 8601) | The message's Date header, or the time it arrived when it had none. |
spam_verdict | string | null | The provider's spam verdict, such as PASS or FAIL. |
spf_verdict | string | null | The provider's SPF verdict, such as PASS, FAIL or GRAY. |
dkim_verdict | string | null | The provider's DKIM verdict. |
dmarc_verdict | string | null | The provider's DMARC verdict. |
sender_authenticated | boolean | true 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. |
automated | boolean | null | true 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.