Receiving
Webhooks
Rather than polling, register an endpoint and we post events to it as they happen. Every delivery is signed and timestamped, and every retry carries the same delivery id so a redelivery can never be mistaken for a second event.
What arrives
POST /your/endpoint HTTP/1.1
content-type: application/json
user-agent: Posthaste-Webhooks/1.0
posthaste-signature: t=1760000000,v1=6f1b0a…
posthaste-delivery-id: evt_Nc7HrQ8T2Sf9pXbAZLm3kQ
posthaste-attempt: 1
{
"id": "evt_Nc7HrQ8T2Sf9pXbAZLm3kQ",
"type": "bounced",
"createdAt": "2026-08-18T06:40:11.902Z",
"data": {
"messageId": "msg_AZLm3kQ8T2Sf9pXbNc7HrQ",
"detail": { "smtp_code": 550, "diagnostic": "5.1.1 No such user here" }
}
}| Field | Notes |
|---|---|
| id | The event id, evt_ prefixed. Identical to the Posthaste-Delivery-Id header. |
| type | One of the ten below. |
| createdAt | When the event occurred, ISO 8601 UTC. Not when this attempt was sent. |
| data.messageId | The msg_ id, or null for an event not tied to one message. |
| data.detail | The event’s own payload — SMTP codes, diagnostic text, the MX we reached. Its shape varies by type; read it defensively. |
Headers
| Header | Meaning |
|---|---|
| Posthaste-Signature | Timestamp and HMAC, as t=<unix seconds>,v1=<hex>. |
| Posthaste-Delivery-Id | Stable across retries. Use it to deduplicate — a redelivery is not a second event. |
| Posthaste-Attempt | Which attempt this is, starting at 1, so you can tell a retry apart from a first delivery. |
Event types
These ten are the complete set. They are the same values that appear in a message’s waybill.
| Type | Meaning |
|---|---|
| accepted | We took the message. This is the event behind a 202, written in the same transaction as the message itself. |
| queued | The message is waiting for a delivery attempt. |
| attempted | We opened a connection to the recipient’s server. The detail names the MX we reached. |
| delivered | The receiving server accepted it. The detail carries its response. |
| deferred | A temporary failure (4xx). We will retry. This is not a bounce and does not suppress. |
| bounced | A permanent failure (5xx). The address is suppressed. The detail carries the server’s diagnostic text. |
| complained | The recipient reported the message as spam. The address is suppressed permanently. |
| failed | We gave up after exhausting delivery retries. |
| rejected | We refused the message rather than attempting it. |
| suppressed | The recipient was already on the suppression list. |
A message does not pass through all of them. The common happy path is accepted → attempted → delivered; a bad address is accepted → attempted → bounced. Write your handler as a switch with a default, not as a state machine that assumes an order.
Verifying the signature
The timestamp is signed with the body. Signing the body alone would be replayable for ever — anyone who captured one valid request could resend it indefinitely and it would still verify.
import express from 'express'
import { verifyWebhook, DELIVERY_ID_HEADER } from '@posthaste/sdk'
const app = express()
app.post(
'/hooks/posthaste',
// THE RAW BYTES. express.json() would hand you an object you cannot
// re-serialise byte-for-byte, and the signature is over exactly what we sent.
express.raw({ type: 'application/json' }),
(req, res) => {
const result = verifyWebhook(
req.body, // a Buffer
req.get('posthaste-signature'),
process.env.POSTHASTE_WEBHOOK_SECRET,
)
if (!result.valid) {
// 'malformed_header' | 'unsupported_version' | 'timestamp_too_old'
// | 'timestamp_in_future' | 'signature_mismatch'
console.warn('rejected webhook', result.reason)
return res.sendStatus(400)
}
const event = JSON.parse(req.body.toString('utf8'))
// Deduplicate on the delivery id — a retry is not a second event.
// Then acknowledge FAST and do the work elsewhere.
enqueue(req.get(DELIVERY_ID_HEADER), event)
return res.sendStatus(204)
},
)verifyWebhook from the SDK does all of the below: it parses the header by key rather than positionally, compares in constant time, and rejects a timestamp outside the tolerance in either direction. The hand-written versions are there because the API is not TypeScript-only — they implement exactly the same thing.
| Detail | Value |
|---|---|
| Algorithm | HMAC-SHA256, hex encoded, lower case. |
| Signed payload | The timestamp, a literal full stop, then the raw body: `${t}.${rawBody}`. |
| Secret | The signingSecret returned once when the endpoint was created. |
| Tolerance | 300 seconds, applied in both directions. |
| Version tag | v1. A header carrying a timestamp and no v1 is either a newer scheme or a truncated header — reject it either way. |
Verify over the raw bytes, before parsing. This is the mistake that makes an otherwise correct implementation fail intermittently and then get “fixed” by skipping verification. Most frameworks parse JSON before your handler runs, and re-serialising the parsed object does not reproduce our bytes — key order, whitespace and number formatting are all free to differ.
In Express, mount express.raw() on the webhook route — or, if the route also needs the parsed body, capture the bytes with express.json({ verify }) as the third tab above shows. In Next.js route handlers, use await req.text(). In Flask, request.get_data().
Compare in constant time. A plain === leaks, through timing, how many leading hex characters matched — which is enough to forge a signature one character at a time.
Retries
Any non-2xx response, a connection failure, or a timeout after 10 seconds is retried. There are seven attempts after the first, on a fixed ladder:
| Retry | After |
|---|---|
| 1 | 10 seconds |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 6 hours |
| 7 | 12 hours |
After the last one the delivery is marked failed and abandoned. Two responses stop delivery immediately instead: 404 and 410 Gone, because the endpoint is telling us it no longer exists and retrying for twelve hours would be rude rather than diligent.
Redirects are not followed. A customer URL that 302s to an internal address would turn our worker into an SSRF vector, and a signed payload arriving somewhere you did not configure is worse than a failed delivery. Register the final URL.
Acknowledge quickly and do the work afterwards. The request times out after 10 seconds. Verify, enqueue, return 204 — a handler that writes to three systems before responding will start timing out under exactly the load that makes the events worth having.
An endpoint that stops accepting anything is not disabled automatically. If yours has been gone for a while, delete it — otherwise every event on the account keeps generating deliveries that can only fail.
Register an endpoint
/v1/webhookswebhooks:write| Field | Type | Notes |
|---|---|---|
| url | string required | Up to 2,000 characters. Must be http:// or https:// — javascript: and data: are refused, because this value is later handed to a fetch. |
| eventTypes | array optional | Up to 20 type names. Empty or absent means every event type, which is what somebody who has just created an endpoint expects. Unknown names are accepted and simply never match. |
curl -X POST https://api.posthastemail.dev/v1/webhooks \
-H "authorization: Bearer $POSTHASTE_KEY" \
-H "content-type: application/json" \
-d '{
"url": "https://yourapp.com/hooks/posthaste",
"eventTypes": ["delivered", "bounced", "complained"]
}'
# 201
# {
# "id": "whk_4Rm8Ub2Xc6Ye0Zf1Ag3Bhk",
# "url": "https://yourapp.com/hooks/posthaste",
# "eventTypes": ["delivered", "bounced", "complained"],
# "status": "active",
# "signingSecret": "whsec_9pXbNc7HrQ8T2Sf3kQAZLm4jR8dMxN2pW7gTaV5oU0e"
# }
#
# signingSecret is shown ONCE and is not retrievable. Store it now.The signingSecret is returned exactly once. No endpoint will show it again — a signing secret a caller can re-read is one an attacker with a stolen API key can read too. If you lose it, delete the endpoint and create another.
| Status | When |
|---|---|
| 201 | Created and immediately active. |
| 400 invalid_request | The URL is missing, too long, or not an http(s) URL. |
| 403 forbidden | The key lacks webhooks:write. |
List endpoints
/v1/webhookswebhooks:readNewest first, not paginated. Each row carries id, url, eventTypes, status (active or disabled) and createdAt. The signing secret is deliberately absent.
Delete an endpoint
/v1/webhooks/:idwebhooks:writeReturns 204, or 404 not_found for an unknown id, another account’s id, or an id that is not a whk_ id. Deleting stops future deliveries; it does not cancel deliveries already queued.
NextErrors →Every error type the API returns, what causes it, and whether to retry.