Skip to content

Webhook best practices

Verify the signature, enqueue the event (database row, queue, background job) and return 2xx immediately. SpacePost waits at most 15 seconds, and a slow handler turns into retries and duplicates.

Delivery is at least once. Store the webhook-id of each processed delivery (a unique constraint is enough) and skip repeats.

const inserted = await db.insert(processedWebhooks).values({ id: webhookId }).onConflictDoNothing().returning();
if (inserted.length === 0) return new Response("duplicate", { status: 200 });

Use the raw body, reject timestamps older than 5 minutes, compare signatures in constant time (the SDK and libraries do). See Verifying signatures.

Events can arrive out of order, especially after retries. Compare the payload’s created_at with what you have stored, and only apply changes that move state forward. SpacePost’s own last_event follows the same rule.

Event Typical action
email.bounced Mark the address invalid in your database; stop sending to it.
email.complained Unsubscribe the user from non-essential mail immediately.
email.delivery_delayed Usually nothing; alert if it is a time-critical email (password reset).
email.failed Log data.failed.reason, alert, possibly retry with a fix.
email.suppressed A recipient was on your suppression list; show it in your admin UI.

Subscribe only to the events you handle. Fewer events means less traffic and fewer failures.

  • Serve over HTTPS with a valid certificate.
  • Do not log secrets or full payloads containing personal data.
  • Use a dedicated path with no other behavior and return a generic body.
  • Keep the signing_secret in a secret manager, not in source control.

Watch the dashboard delivery log or alert on repeated failures: if your endpoint is down for more than about 27 hours all retries are used up, after which you must replay manually.