Verify the signature against the original request bytes, persist each event under a unique source-and-event key, then acknowledge it. Process the stored inbox independently. Delivery callbacks can repeat or arrive out of order, so they should not blindly overwrite message state.
Treat acknowledgement as a persistence boundary
A webhook sender cannot know whether your application saved an event when the connection breaks. It may retry a notification you already processed. That is normal distributed-system behavior, not necessarily a provider defect.
Returning success before storage finishes risks losing the event. Waiting for every downstream action before responding makes the endpoint slow and fragile. A durable inbox gives you a smaller promise: the event is safely recorded, and application processing can resume later.
Use a unique combination of provider or endpoint identity and event ID. Two sources can choose overlapping identifiers. Do not deduplicate by message ID alone because one message can legitimately have several events.
Verify the original payload before parsing business data
Use the provider’s supported verification library and its exact signing format. Stampwing’s outgoing webhooks follow the Standard Webhooks pattern with webhook-id, webhook-timestamp, and webhook-signature headers. Verification includes the timestamp and unmodified body.
Do not parse and reserialize JSON before checking the signature. Whitespace and property order can change the signed bytes. Keep the server clock accurate, enforce body and request-time limits, and support the provider’s documented secret-rotation process.
The example uses standardwebhooks to handle signature verification and freshness checks. A valid signature establishes authenticity for the configured key; validate event shape and project/message references before applying business effects.
import { Webhook } from "standardwebhooks";
const verifier = new Webhook(process.env.WEBHOOK_SECRET);
// rawBody is the unmodified UTF-8 request body.
const event = verifier.verify(rawBody, request.headers);Commit once, acknowledge duplicates consistently
Create the example inbox table in your application’s own database. Download the receiver, install pg and standardwebhooks, and configure your database connection and endpoint signing secret in the server environment.
The receiver limits the body, verifies the signature, and inserts the event with ON CONFLICT DO NOTHING. It returns 204 only after the database operation completes. If storage fails, it returns 503 so the sender can retry.
This receiver is a local integration example listening on loopback. Deploy behind an appropriately configured HTTPS ingress for a real endpoint, and add your operational monitoring and retention policy. The example does not process application effects or send email.
npm install pg standardwebhooks
# Apply webhook-inbox.sql to your application database.
# Set DATABASE_URL and WEBHOOK_SECRET in your server environment.
node webhook-receiver.mjsPreserve the timeline instead of trusting arrival order
An accepted event may arrive before an earlier submitted event. A delayed delivery event may arrive after your own timeout warning. Keep both provider occurrence time and local receipt time so you can explain what happened.
Model observations by meaning. Delivery, complaints, and suppression changes are not interchangeable points on a single numeric progress scale. Derive a user-facing status with explicit precedence and keep the original events for diagnosis.
When processing the inbox, claim rows safely and commit local state changes together with the processed marker. For an external side effect, write another durable outbox entry in that transaction. A database transaction cannot atomically commit an unrelated remote request.
| Arrival pattern | Expected behavior |
|---|---|
| Same event arrives twice | One inbox row, two successful acknowledgements. |
| Older event arrives later | Retain both observations; do not erase newer evidence. |
| Unknown message reference | Store or quarantine for reconciliation; do not attach to another project. |
| Worker crashes mid-processing | Resume from persisted state with an idempotent effect. |
| Database unavailable | Return a retryable failure; do not claim success. |
Replay the same event, then alter its body
The downloadable replay script signs a synthetic event with the same local secret, posts it twice, and verifies that a tampered copy is rejected. Run it after the receiver and query the inbox: there should be one row for fixture-delivery-42.
A replay is a synthetic protocol test. It does not establish real provider connectivity. For a complete application test, also exercise an unavailable database, an expired signature, two different events for the same message, and a worker crash after claiming an inbox row.
node webhook-replay.mjs
# Inspect your application database:
# SELECT source, event_id, count(*)
# FROM email_webhook_inbox GROUP BY source, event_id;Watch the inbox after the endpoint is healthy
An endpoint returning 204 can coexist with a broken processing worker. Monitor the oldest unprocessed event, repeated processing errors, and reconciliation backlog separately from HTTP response rates.
Document how to replay a stored event safely, rotate a secret, and recover from a database outage. Include identifiers and timestamps in logs, but avoid logging full message bodies, signing secrets, or recipient data unnecessarily.
Sources and further reading
Written for Stampwing with AI assistance and checked against the linked documentation. Examples are educational; simulated results are labelled. How these resources are maintained.