Guides

Webhooks

Get delivery, bounce, complaint, open and click events pushed to your server, signed so you can trust them.

A webhook endpoint is an HTTPS URL on your server. When something happens to an email, SendHawk.Online sends it a signed POST with the event as JSON. Webhooks are how you keep your own records in step without polling.

Add an endpoint

Owners and admins add endpoints in Settings > Webhooks: the URL, which must start with https://, and the events to receive.

The endpoint's signing secret is shown once, when you create it. Store it next to your other secrets; you need it to verify signatures.

The Webhooks guide walks through the settings page, test events and the delivery log.

Events

EventSent whenSource
sentAmazon SES accepted the message for delivery.SendHawk.Online, right after Send email succeeds
deliveredThe recipient's mail server accepted the message.Amazon SES
bouncedThe message bounced, permanently or after Amazon SES stopped retrying.Amazon SES
complainedThe recipient marked the message as spam.Amazon SES, through the mailbox provider's feedback loop
openedThe recipient opened the email, counted once per email.Open tracking
clickedThe recipient clicked a link, counted once per link per email.Click tracking

An endpoint subscribes to any of these six. The other email statuses, queued and failed, never produce a webhook event, so they are not offered.

Send test event in Settings > Webhooks delivers one event of type test to that endpoint, with data set to { "test": true }, so you can check your handler and signature code before real mail flows.

Payload

Every event has the same envelope:

{
  "id": "0193a6c1-8f2e-7d40-b5a1-3c9e0d7f4a21",
  "type": "delivered",
  "createdAt": "2026-11-03T09:15:04.120Z",
  "data": {
    "messageId": "4f1c2b8e-7a3d-4e5f-9b21-0c6d8e9f1a2b",
    "to": "ada@example.com",
    "providerMessageId": "010701932f6b8c1e-5a7d9c3e-4b2f-4e8a-9d1c-7f6e5d4c3b2a-000000",
    "timestamp": "2026-11-03T09:15:04.120Z",
    "processingTimeMillis": 1639,
    "recipients": ["ada@example.com"],
    "smtpResponse": "250 2.0.0 OK",
    "reportingMTA": "a8-27.smtp-out.amazonses.com",
    "remoteMtaIp": "203.0.113.25"
  }
}
id
string
The event's id. The same event can be delivered more than once, so use this to skip events you have already processed.
type
string
The event type from the table above. Also sent as the X-SendHawk-Event header.
createdAt
string
When the event happened, as an ISO 8601 timestamp in UTC. For Amazon SES events this is the time SES reports.
data.messageId
string
The email's id, as returned by Send email.
data.to
string
The recipient the email was sent to.
data.providerMessageId
string
The message id Amazon SES assigned, useful when you talk to SES support or read raw headers.

The rest of data depends on the event. For events from Amazon SES it is the object SES published, passed through as SES sent it; fields SES leaves out are absent.

EventFields in data
deliveredtimestamp, processingTimeMillis, recipients, smtpResponse, reportingMTA, remoteMtaIp
bouncedbounceType (Permanent, Transient or Undetermined), bounceSubType, bouncedRecipients (each with emailAddress, and action, status and diagnosticCode when the bounce carried them), timestamp, feedbackId, reportingMTA
complainedcomplainedRecipients (each with emailAddress), timestamp, feedbackId, complaintSubType, userAgent, complaintFeedbackType, arrivalDate
openeduserAgent
clickedurl, userAgent

Amazon SES describes its fields in the contents of SES event data.

Headers

HeaderValue
Content-Typeapplication/json
X-SendHawk-EventThe event type, for example bounced.
X-SendHawk-DeliveryThe delivery's id. It stays the same on every retry of the same event, so you can log and correlate attempts.
X-SendHawk-TimestampWhen this delivery attempt was signed, in Unix seconds.
X-SendHawk-Signaturesha256= followed by the hex HMAC-SHA256 signature.

Verify signatures

Anyone can POST to a public URL, so check the signature before you trust an event. SendHawk.Online signs the string <timestamp>.<raw request body> with HMAC-SHA256, keyed with your endpoint's signing secret, and sends the hex digest as X-SendHawk-Signature: sha256=<hex>.

To verify a request:

  1. Read the raw request body as bytes, before any JSON parsing. Re-serialized JSON does not match byte for byte.
  2. Reject the request if X-SendHawk-Timestamp is more than 5 minutes from your clock. That stops an attacker from replaying a captured request later.
  3. Compute HMAC-SHA256 of <timestamp>.<raw body>, keyed with the signing secret exactly as shown when you created the endpoint (the 64-character string itself, as UTF-8 - do not hex-decode it).
  4. Compare your digest with the one after sha256= in constant time, never with ==.
import crypto from 'node:crypto'
import express from 'express'

const secret = process.env.SENDHAWK_WEBHOOK_SECRET
const TOLERANCE_SECONDS = 5 * 60

function isValidSignature(rawBody, timestamp, signature) {
  if (!/^\d+$/.test(timestamp ?? '') || !signature?.startsWith('sha256=')) return false
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest()
  const received = Buffer.from(signature.slice('sha256='.length), 'hex')

  return received.length === expected.length && crypto.timingSafeEqual(received, expected)
}

const app = express()

// express.raw keeps the body as a Buffer, exactly as it was signed.
app.post('/webhooks/sendhawk', express.raw({ type: 'application/json' }), (req, res) => {
  const valid = isValidSignature(
    req.body,
    req.get('X-SendHawk-Timestamp'),
    req.get('X-SendHawk-Signature')
  )
  if (!valid) return res.sendStatus(400)

  const event = JSON.parse(req.body.toString('utf8'))
  // Acknowledge first, then process: queue the event and skip ids you have seen.
  res.sendStatus(200)
  console.log(event.type, event.data.messageId)
})

app.listen(3000)

Respond quickly

Answer with any 2xx status within 5 seconds. Anything else counts as a failed attempt: another status (redirects included - they are never followed), a timeout, a TLS error or a refused connection. Do the real work after responding, from a queue, so a slow database never turns into a retry.

Retries

A failed delivery is retried with exponential backoff, up to 10 attempts in total:

AttemptWhen
1Within about a minute of the event
22 minutes after attempt 1 failed
34 minutes after attempt 2
48 minutes after attempt 3
516 minutes after attempt 4
632 minutes after attempt 5
764 minutes after attempt 6
8128 minutes after attempt 7
9256 minutes after attempt 8
106 hours after attempt 9

Deliveries go out on a one-minute schedule, so each wait can run up to a minute longer. After the tenth failure the event is dropped, about 14 and a half hours after it happened.

Deliveries on an endpoint in Settings > Webhooks shows the last 50 deliveries with their attempts, the HTTP status your server answered and when the next attempt is due:

Because of retries, design your handler for three things:

  • Duplicates. An attempt can reach you, time out on the way back and be sent again. Deduplicate on id.
  • Order. A retried event can arrive after a newer one. Use createdAt, not arrival order, when it matters.
  • Gaps. An event dropped after ten failures is not sent again. Retrieve email has the full history of any email.