PosthasteDocsGet 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 thirteen 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.

data.detail on accepted

For a message sent immediately, the accepted event’s detail always carries source — 'api' or 'smtp' — depending on whether the message came in through POST /v1/emails or the SMTP relay. When the message carried attachments, the same detail also carries an attachments array: metadata only, never content, one entry per file.

{
  "id": "evt_Nc7HrQ8T2Sf9pXbAZLm3kQ",
  "type": "accepted",
  "createdAt": "2026-08-22T09:12:03.441Z",
  "data": {
    "messageId": "msg_AZLm3kQ8T2Sf9pXbNc7HrQ",
    "detail": {
      "to_domain": "example.com",
      "from_domain": "yourdomain.com",
      "source": "smtp",
      "attachments": [
        {
          "filename": "invoice.pdf",
          "content_type": "application/pdf",
          "size_bytes": 84213,
          "sha256": "9f2c1b7e4a3d…"
        }
      ]
    }
  }
}

# "attachments" is omitted entirely when the message had none — never an
# empty array.
FieldNotes
source'api' or 'smtp'. Present on an accepted event written for an immediate send, regardless of whether the message had attachments.
attachmentsPresent only when the message had at least one. Each entry carries filename, content_type, size_bytes and sha256 — the same fields the message record itself stores, useful for confirming what was attached without fetching the message.

A scheduled send splits accepted in two

A message you scheduled does not get an accepted event at all when you send it. Instead source, the domains and any attachments array — the fields above — are written on the scheduled event, alongside a scheduled_at echo. accepted is written later, on release, and its detail is deliberately thin — { scheduled: true, scheduled_at } — because the rest was already sent on scheduled and repeating it would be noise. If you key an integration off accepted alone expecting source to always be there, a scheduled send is where that assumption breaks; branch on data.detail.scheduled and readsource from the earlier scheduled event instead. See scheduling.

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 thirteen 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. For a scheduled send this can also fire on release day rather than at accept — the detail then carries phase: "scheduled_release".
scheduledA send was accepted for later rather than now.
schedule_canceledYou called DELETE /v1/emails/:id/schedule before the send released.
schedule_failedA scheduled send was refused when it came due — an unverified domain, or quota exhausted past the retry grace window. The detail carries the refusal’s reason and its numbers, the same shape the equivalent live-send refusal would carry.

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_example_secret_Nc7HrQ8T2Sf3kQAZLm4jR8dMxN2pW7g"
# }
#
# 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.

NextErrors →Every error type the API returns, what causes it, and whether to retry.