Skip to content
Pillar guide

The Complete Guide to Webhooks

Updated 2026-06-13Reviewed by Florian Wartner

What a webhook is (and when to use one)

A webhook is a reverse API call. Instead of your application repeatedly asking a service "has anything happened yet?", the service calls you - an HTTP POST to a URL you control - the moment an event occurs. The classic phrasing is that webhooks are "user-defined HTTP callbacks"; you register a URL once, and the provider pushes data to it whenever the relevant thing happens.

The alternative is polling, and the contrast is stark. A poller asking every minute wastes thousands of empty requests a day and still adds up to a minute of latency to every real event. A webhook fires within seconds of the event and sends zero requests when nothing is happening. For anything event-driven - a form submission arriving, a payment settling, a build finishing - webhooks are the correct primitive.

Use a webhook when another system needs to react to an event in near real time and you control code at the receiving end: create a CRM contact when a lead submits, open a support ticket, post to a team channel, kick off a background job, or sync a record to your data warehouse. Reach for a no-code integration instead when the destination is a common SaaS tool and you do not want to host an endpoint, and reach for the REST API when you need to ask the question on your own schedule (a nightly export, an on-demand lookup). The three are complementary; mature stacks use all of them. Formspring fires a signed webhook on submission events, exposes the same data over a REST API, and offers direct integrations for the common destinations.

The anatomy of a webhook delivery

Every webhook delivery is an ordinary HTTP request, and understanding its parts is what lets you handle it safely.

  • Method and URL. Almost always POST to the endpoint URL you registered. Your endpoint must be publicly reachable over HTTPS - plain HTTP leaks the payload in transit and is rejected by most senders.
  • Headers. This is where the metadata lives. Expect a Content-Type (usually application/json), a signature header carrying an HMAC and a timestamp, and frequently a unique delivery or event ID header. The signature header is the one that makes the request trustworthy; the ID header is the one that makes it safe to retry.
  • Body. A JSON payload describing the event: an event type, a timestamp, and the resource that changed (the submission, the payment, the record). Treat the body as untrusted until the signature checks out.
  • The expected response. Your endpoint should return a 2xx status quickly to acknowledge receipt. Any non-2xx (or a timeout) tells the sender the delivery failed, which triggers a retry. The single most common production mistake is doing slow work before returning 2xx, so a healthy delivery times out and gets retried needlessly.

The mental model that prevents most bugs: a webhook is an at-least-once notification, not an exactly-once command. The network can duplicate it, reorder it, and replay it. Everything in the rest of this guide follows from designing for that reality. Formspring's webhook payload documents the exact envelope, fields, and headers you will receive.

Verifying HMAC signatures

Your webhook URL is a public POST endpoint. Anyone who learns it - from a log, a referrer header, a leaked config - can forge requests to it. Signature verification is what turns "any request that hits this URL" into "a request I can prove came from the real sender."

The standard mechanism is an HMAC (hash-based message authentication code). The sender shares a secret with you out of band, then for each delivery computes HMAC-SHA256(secret, signed_payload) and sends the result in a header. You recompute the same HMAC on your side using the raw request body and your copy of the secret; if the two match, the payload is authentic and untampered. RFC 2104 defines HMAC; the security property you are relying on is that without the secret, an attacker cannot produce a valid signature for a payload they chose.

Three details separate a correct implementation from a broken one:

  1. Sign the raw bytes, not the parsed object. Re-serializing JSON reorders keys and changes whitespace, which changes the hash. Capture the request body before any framework parses it, and HMAC exactly those bytes.
  2. Compare in constant time. Use a timing-safe comparison (hash_equals in PHP, crypto.timingSafeEqual in Node, hmac.compare_digest in Python) so an attacker cannot probe the secret byte-by-byte via response-time differences.
  3. Check the timestamp. Signed payloads typically prefix a timestamp (t=...,v1=...). Recompute the HMAC over timestamp + payload, and reject deliveries whose timestamp is older than a few minutes. This defeats replay attacks where someone captures a valid delivery and re-sends it later.

Formspring signs every delivery with a timestamped SHA-256 HMAC, and the step-by-step verification post shows correct, copy-pasteable receivers in Node, PHP, and Python. If you implement only one thing from this guide, implement signature verification - an unsigned webhook handler is an unauthenticated write endpoint.

Idempotency: handling duplicate deliveries

Because webhooks are delivered at least once, your endpoint will receive the same event more than once - after a retry, after a network blip that ate your 2xx, after a sender-side replay. If processing a duplicate creates a second CRM contact, sends a second confirmation email, or charges a card twice, you have a correctness bug waiting for a busy day.

The fix is idempotency: make processing the same event twice indistinguishable from processing it once. The reliable pattern uses a stable identifier:

  1. Read the unique event/delivery ID from the payload or header (every well-designed webhook provides one).
  2. Before doing any work, check whether you have already processed that ID - a unique column in your database, a key in a fast store with a TTL, or a dedicated processed_events table.
  3. If it is new, record the ID and do the work in the same transaction so a crash between the two cannot leave you half-done. If it already exists, acknowledge with 2xx and stop.

A database unique constraint on the event ID is the most robust version: an attempted duplicate insert fails atomically, and you treat that failure as "already handled." Avoid using payload contents or arrival time as the dedup key - only the provider's stable event ID survives reordering and partial failures.

Idempotency and retries are two halves of one design: retries guarantee you eventually get the event, idempotency guarantees getting it twice is harmless. Formspring includes a stable delivery ID on every webhook for exactly this, and the idempotency deep-dive walks through the dedup table and transaction pattern in full.

Retries and exponential backoff

Endpoints go down. They deploy, they hit a slow query, they get rate-limited, they 500. A webhook sender that gave up on the first failure would silently lose events; a sender that retried immediately and forever would hammer a struggling endpoint into staying down. The answer both sides should expect is retries with exponential backoff.

The sender retries failed deliveries (non-2xx or timeout) on a schedule that doubles the wait each time - say 1 minute, then 2, 4, 8, 16, and so on, often with a little random jitter added so a fleet of failed deliveries does not retry in a synchronized thundering herd. Google's SRE guidance on handling overload and AWS's writeup on backoff and jitter both make the same case: backoff gives the dependency room to recover, and jitter spreads the load. Retries usually continue up to a maximum window (commonly 24-72 hours) before the delivery is marked permanently failed.

What this means for you, the receiver:

  • Return 2xx fast. Acknowledge first, process asynchronously. If you do the slow work before responding, a retry may arrive before the first attempt finishes, and now you are processing the same event in parallel.
  • Distinguish retryable from fatal failures. Return 5xx (or a timeout) when you want the sender to try again - a transient DB outage. Return 4xx for a payload you will never accept, so the sender stops wasting attempts. Never return 2xx for an event you failed to process; that tells the sender to forget it forever.
  • Make retries safe. This is where idempotency from the previous section pays off - every retry hits the same dedup guard.

Formspring retries failed deliveries with exponential backoff automatically, and the retry-strategies explainer covers the backoff math and the receiver-side rules in detail.

Replaying and debugging failed deliveries

Even with retries, deliveries permanently fail - your endpoint was down longer than the retry window, or a bug in your handler 500'd every attempt. When you fix the problem, you need to recover the events you missed, and you need a record to debug why they failed. That is what a delivery log and replay are for.

A good webhook system keeps a delivery log: for each attempt, the timestamp, the response status and body your endpoint returned, the latency, and the attempt number. This turns debugging from guesswork into reading. "Every delivery since 14:00 got a 502" points straight at a deploy; "401 Unauthorized" points at your signature check rejecting valid payloads (usually because you hashed the parsed body instead of the raw bytes).

Replay lets you re-send a past delivery on demand once your endpoint is healthy again - recovering missed events without asking the sender to manually re-trigger anything. Because you built idempotency, replaying a delivery you actually did process is harmless: it hits the dedup guard and no-ops.

When you are developing locally, you do not want to deploy to test a handler. Capture real payloads from the delivery log and feed them to your endpoint with curl, or use a tunnel that exposes your local server to the sender. Verify the signature path works against a real signed payload early - it is the part most likely to be subtly wrong. Formspring keeps a full delivery log with one-click replay, and the post on why deliveries fail and how to replay them catalogues the failure signatures and their fixes.

Building a receiver that does not fall over

Pulling the pieces together, a production webhook receiver follows the same shape regardless of language or framework:

  1. Read the raw body before parsing. You need the exact bytes for signature verification.
  2. Verify the signature with a timing-safe comparison, and check the timestamp to reject replays. Reject with 401 if it fails - and log it, because a flood of 401s usually means your verification is broken, not an attack.
  3. Check idempotency. Look up the event ID; if seen, return 2xx and stop.
  4. Persist the event and return 2xx immediately. Write the raw event to a queue or table, acknowledge, and get out of the request cycle. Do not call your CRM, send email, or run reports inside the request.
  5. Process asynchronously. A background worker does the real work - and because the event is already stored idempotently, that worker can itself retry safely.

The two failure modes this shape prevents are the ones that bite everyone. Doing slow work inline causes timeouts and duplicate processing under retry. Trusting the payload before verifying lets a forged request write to your systems. Get the order right - raw body, verify, dedup, ack, process - and the endpoint stays boring under load, which is exactly what you want from it.

Keep the handler small and the worker idempotent, and a webhook receiver becomes one of the most reliable parts of your stack rather than the 3 a.m. page. The webhook configuration docs cover setting the endpoint, secret, and event types; the recipes show end-to-end receivers wired to common destinations.

Security hardening checklist

Beyond signatures, a handful of measures close the remaining gaps. Treat this as a pre-launch checklist for any endpoint that accepts webhooks:

  • HTTPS only. Reject plain HTTP; the payload and headers are sensitive in transit.
  • Verify the signature on every request, before any processing, with a constant-time compare. No exceptions for "internal" traffic.
  • Enforce a timestamp tolerance (a few minutes) to defeat replay of captured deliveries.
  • Store the signing secret as a secret - environment variable or secret manager, never in source control or client-side code. Rotate it if it may have leaked, and support two valid secrets briefly during rotation so in-flight deliveries do not fail.
  • Validate and bound the payload. Cap the body size you will accept, validate the schema, and never eval or interpolate payload fields into shell, SQL, or templates - a webhook body is attacker-influenceable input even when authenticated.
  • Do not leak in errors. Return generic failure messages; a verbose stack trace in the response body hands an attacker a map of your internals.
  • Rate-limit and alert. A sudden spike in 401s, a flood from one source, or a delivery-failure alarm should reach a human. Watch the delivery log the way you watch error rates.
  • Keep the URL unguessable but not secret. Treat the endpoint path as discoverable; your security comes from the signature, not from a hidden URL.

None of these is expensive, and together they turn a public POST endpoint into a trustworthy one. The signing documentation and the broader security overview cover how Formspring implements the sender side of this checklist.

Webhooks vs polling vs integrations

Three ways to move an event from one system to another, each right in different situations.

Polling has your code ask the source on a schedule. It is simple and needs no public endpoint, which makes it the fallback when you cannot receive inbound requests (behind a corporate firewall, a CLI script, a notebook). The cost is latency and waste: you trade real-time for an interval, and you send mostly-empty requests forever. Poll when you genuinely cannot accept a callback, or when you need a periodic snapshot rather than per-event reactions.

Webhooks invert that: the source pushes to you in near real time, with no wasted requests. The cost is operational - you host an endpoint and you own signature verification, idempotency, and retry handling. Choose webhooks when reactions must be timely and you control code at the receiving end. This guide is the manual for doing that part well.

Direct integrations are a managed webhook you do not have to build. The provider runs the endpoint and the plumbing; you connect an account and pick a destination. Choose an integration when the target is a common tool - a team chat, a spreadsheet, a CRM, an automation platform - and you would rather not host or maintain a receiver. The trade is flexibility: you get what the integration offers, not arbitrary custom logic.

The right architecture is rarely one of these alone. A typical setup fires a direct integration to Slack for instant human visibility and a webhook to the system of record for durable processing, while reserving the REST API for scheduled exports. Formspring offers all three against the same submission data, so you can match each consumer to the mechanism that fits it.

Common questions

Frequently asked

How do I verify a webhook signature?
Capture the raw request body before parsing, compute HMAC-SHA256 over the signed payload (usually timestamp + body) using your shared secret, and compare it to the signature header with a timing-safe comparison. If they match, the payload is authentic. Also reject deliveries whose timestamp is more than a few minutes old to defeat replay attacks. Formspring documents this with copy-pasteable Node, PHP, and Python receivers.
Why am I receiving the same webhook twice?
Webhooks are delivered at least once, so duplicates are expected - usually from a retry after your endpoint was slow to return 2xx, or a network blip that lost your acknowledgement. Make processing idempotent: read the unique event ID, check whether you have handled it (a unique DB column works well), and skip the work if you have. Done right, a duplicate is harmless.
What should my endpoint return to a webhook?
Return a 2xx status quickly to acknowledge receipt, then process the event asynchronously. Return 5xx or time out when you want the sender to retry a transient failure, and 4xx for a payload you will never accept so the sender stops trying. Never return 2xx for an event you failed to process - that tells the sender to forget it permanently.
How do webhook retries work?
When a delivery fails (non-2xx or timeout), a well-built sender retries on an exponential-backoff schedule - roughly 1, 2, 4, 8 minutes and so on, often with random jitter - up to a maximum window of a day or more before marking it permanently failed. Backoff gives your endpoint room to recover; jitter prevents synchronized retry storms. Formspring retries automatically and logs every attempt.
What happens if my endpoint was down and I missed events?
Automatic retries recover deliveries that fail temporarily. For events lost beyond the retry window, use the delivery log to find the failed deliveries and replay them once your endpoint is healthy again - this re-sends the original payload without manual re-triggering. Because your handler is idempotent, replaying an event you already processed is a safe no-op.

Give your next important form a real home.

Start free with one form. Add ownership, private files, and clear history before responses pile up in inboxes.

·· no card · 50 submissions / mo · no countdown