Webhooks
Get events pushed to you as they happen, signed so you can prove they came from us.
Register an endpoint and OutSend will POST each event to it as it arrives. Every endpoint gets its own signing secret and its own delivery log — so when a webhook fails, that is visible too.
Events
| Event | Fires when |
|---|---|
email.delivered | The receiving provider accepted the message. |
email.bounced | Permanent or transient failure, with the reason kept. |
email.opened | Tracking pixel loaded, where open tracking is enabled. |
email.clicked | A tracked link was followed. |
email.complained | The recipient marked it as spam. |
email.unsubscribed | The recipient used the unsubscribe link. |
Open and click tracking are toggled per domain. If you have them off, those events simply do not fire.
Payload
POST /your-endpoint
X-OutSend-Signature: sha256=8c1f…
Content-Type: application/json
{
"event": "email.bounced",
"occurred_at": "2026-09-13T09:14:22Z",
"email_id": 10482,
"recipient": "person@example.com",
"bounce_type": "Transient",
"provider": {
"response": "450 4.7.1 Not accepted, try again later",
"reporting_mta": "mx.example-provider.com"
}
}
The provider object is the part that matters. We normalise events into one schema so you
can handle them uniformly, but we never throw away what the provider actually said.
Verifying the signature
Compute an HMAC-SHA256 of the raw request body using your endpoint’s secret and compare it to the header. Compare in constant time, and do it before you parse anything.
import { createHmac, timingSafeEqual } from "node:crypto"
function verify(rawBody, header, secret) {
const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex")
const a = Buffer.from(header)
const b = Buffer.from(expected)
return a.length === b.length && timingSafeEqual(a, b)
}
def verify(raw_body, header, secret)
expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", secret, raw_body)
ActiveSupport::SecurityUtils.secure_compare(header, expected)
end
Delivery log
Each attempt is recorded with its response code, response body, and attempt count. An
endpoint that keeps failing moves from active to paused, and repeated failure disables
it — with the failure count visible so you can see how you got there.
Respond 2xx quickly. Do the real work in a background job; a webhook handler that takes
four seconds to answer is a webhook handler that will eventually time out.
Retries
Failed deliveries are retried with backoff. Because retries happen, your handler must be
idempotent — key on email_id plus event plus occurred_at and you will be fine.