Get an API key

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" }
  }
}
FieldNotes
idThe event id, evt_ prefixed. Identical to the Posthaste-Delivery-Id header.
typeOne of the ten below.
createdAtWhen the event occurred, ISO 8601 UTC. Not when this attempt was sent.
data.messageIdThe msg_ id, or null for an event not tied to one message.
data.detailThe event’s own payload — SMTP codes, diagnostic text, the MX we reached. Its shape varies by type; read it defensively.

Headers

HeaderMeaning
Posthaste-SignatureTimestamp and HMAC, as t=<unix seconds>,v1=<hex>.
Posthaste-Delivery-IdStable across retries. Use it to deduplicate — a redelivery is not a second event.
Posthaste-AttemptWhich 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.

TypeMeaning
acceptedWe took the message. This is the event behind a 202, written in the same transaction as the message itself.
queuedThe message is waiting for a delivery attempt.
attemptedWe opened a connection to the recipient’s server. The detail names the MX we reached.
deliveredThe receiving server accepted it. The detail carries its response.
deferredA temporary failure (4xx). We will retry. This is not a bounce and does not suppress.
bouncedA permanent failure (5xx). The address is suppressed. The detail carries the server’s diagnostic text.
complainedThe recipient reported the message as spam. The address is suppressed permanently.
failedWe gave up after exhausting delivery retries.
rejectedWe refused the message rather than attempting it.
suppressedThe 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.

DetailValue
AlgorithmHMAC-SHA256, hex encoded, lower case.
Signed payloadThe timestamp, a literal full stop, then the raw body: `${t}.${rawBody}`.
SecretThe signingSecret returned once when the endpoint was created.
Tolerance300 seconds, applied in both directions.
Version tagv1. 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:

RetryAfter
110 seconds
21 minute
35 minutes
430 minutes
52 hours
66 hours
712 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

POST/v1/webhookswebhooks:write
FieldTypeNotes
urlstring requiredUp to 2,000 characters. Must be http:// or https:// javascript: and data: are refused, because this value is later handed to a fetch.
eventTypesarray optionalUp 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.

StatusWhen
201Created and immediately active.
400 invalid_requestThe URL is missing, too long, or not an http(s) URL.
403 forbiddenThe key lacks webhooks:write.

List endpoints

GET/v1/webhookswebhooks:read

Newest first, not paginated. Each row carries id, url, eventTypes, status (active or disabled) and createdAt. The signing secret is deliberately absent.

Delete an endpoint

DELETE/v1/webhooks/:idwebhooks:write

Returns 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.

NextErrorsEvery error type the API returns, what causes it, and whether to retry.