Operate
Webhooks
Receive signed email events (sent, delivered, delayed, bounced, complained, failed, received) at your own HTTPS endpoint, with retries.
Add an endpoint
Open Webhooks in the dashboard, or call POST /api/v1/webhooks with an HTTPS URL. Omit events to receive every event type, or list the ones you want. The response includes the endpoint's signing secret (whsec_…); keep it server-side. A workspace can have up to 10 endpoints.
{
"url": "https://example.com/webhooks/sendhq",
"events": [
"email.delivered",
"email.bounced",
"email.complained"
],
"description": "Production"
}Event types
email.sent- The provider accepted an outbound email.
email.delivered- The recipient's mail server accepted it.
data.recipientslists the addresses. email.delivery_delayed- Delivery is being retried.
data.delayhastype, the receiving server'sdiagnostic(for example a Gmail 4.7.28 rate limit), andexpiration. email.bounceddata.recipientanddata.bouncewithtype(Permanent or Transient),sub_type,diagnostic, andsuppressed.email.complaineddata.recipientanddata.complaint.feedback_type. The address is suppressed.email.failed- The email was not sent.
data.reasonsays why. email.received- An inbound email was stored, with
data.inbox_id.
Payload
Every event is a JSON object with type, created_at, and data. data.email_id is the SendHQ email ID returned when you sent it, alongside from, to, and subject.
{
"type": "email.bounced",
"created_at": "2026-10-29T12:00:00.000Z",
"data": {
"email_id": "em_…",
"from": "news@example.com",
"to": [
"reader@example.net"
],
"subject": "Issue 12",
"recipient": "reader@example.net",
"bounce": {
"type": "Permanent",
"sub_type": "General",
"diagnostic": "550 5.1.1 user unknown",
"suppressed": true
}
}
}Verify the signature
Deliveries are signed with the Standard Webhooks scheme. Each request carries webhook-id, webhook-timestamp, and webhook-signature. The signature is v1, followed by the base64 HMAC-SHA256 of id.timestamp.body, keyed with the base64-decoded secret after whsec_. Compare in constant time, reject timestamps more than five minutes old, and verify the raw body before parsing it. Any Standard Webhooks library works.
import { Webhook } from "standardwebhooks";
const event = new Webhook(process.env.SENDHQ_WEBHOOK_SECRET)
.verify(rawBody, Object.fromEntries(request.headers));Retries and deduplication
Answer with any 2xx status within 10 seconds. Anything else, a timeout, or a redirect is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, and 10 hours: eight attempts over about 27 hours. A retry keeps the same webhook-id, so record it and ignore a delivery you have already processed. Events can arrive out of order.
Inspect and replay
GET /api/v1/webhooks/:id/deliveries lists attempts with the response status and error; filter with status=failed. Retry one with POST …/deliveries/:deliveryId/retry, send a signed webhook.test event with POST /api/v1/webhooks/:id/test, and rotate a leaked secret with POST /api/v1/webhooks/:id/rotate-secret. Delivery logs follow the workspace message retention.