Skip to content
Blog/Webhooks and delivery

Form webhook retry ladders, compared: what actually happens when your endpoint is down

Vellform makes 3 attempts inside about 5 seconds. Typeform retries for hours, Stripe for days. What each ladder costs the receiver.

Vellform Engineering8 min read9 sources
Three nested arcs of increasing width, each marked at its apex, standing for successive delivery attempts spaced further apart.

The ladders, side by side

Every row was read off that vendor's own documentation on 8 September 2026. Vendors rewrite these pages without changelogs, so if you are reading this months later, open the sources and check.

SenderAttemptsIntervalsTotal windowRetriedNot retriedSignature
Vellform3 total~1s, then ~4s~5s5xx, 408, 429Every other 4xxHMAC-SHA256 over the raw body
TypeformSays five times; lists seven intervals5m, 10m, 20m, 1h, 2h, 3h, 4h~10h 35m as listedAny other non-2xx. 429, 408, 503 and 423 get a separate schedule: every 2–3 min for 10 hours404 and 410 — and the webhook is disabled immediatelyTypeform-Signature, HMAC-SHA256, base64
Stripe (live mode)Not publishedExponential backoff, intervals not publishedUp to 3 daysAny non-2xx, plus 3xx redirects, TLS and connection errors, timeoutsNothing by status code; a disabled or deleted destination stops future retriesStripe-Signature, HMAC-SHA256
Slack Events API3 retriesNearly immediately, 1m, 5m~6mNon-2xx, no 2xx within 3s, too many redirects, SSL and connection errorsAny response carrying x-slack-no-retry: 1X-Slack-Signature, HMAC-SHA256
Retry ladders as each vendor's own documentation described them on 8 September 2026.

How to read a retry ladder

At-least-once is a promise about duplicates, not about durability

Every ladder in that table is at-least-once. The name describes the failure you must tolerate — the same event arriving twice — not the one you hoped it ruled out. Nothing in at-least-once promises the event arrives at all. Stripe retries for three days and then stops; Typeform gives up after its last interval. A long ladder buys probability, not certainty.

So the receiver's obligation is the same whichever sender you face: be idempotent. Stripe's own guidance is to log the event IDs you have processed and skip the ones you have seen — and specifically not to use timestamps, because two distinct events can share a created second. What changes between senders is not *whether* you dedupe. It is how long you have to remember.

Why 408 and 429 are the only 4xx worth retrying

Underneath every ladder is the same reading of the status classes. A 4xx says the request was wrong, so sending it again unchanged produces the same answer — RFC 9110 defines 400 as a malformed request the server cannot process. A 5xx says the server failed and might not fail next time. Retrying the first is a loop; retrying the second is the point of having a ladder.

Two 4xx codes break the pattern because they are about time, not about the request. 408 is the server saying it never received a complete request inside its timeout. 429 was defined in RFC 6585 to mean you are going too fast, and that RFC says the response may carry a Retry-After. Neither says your request was wrong. Both say later.

That is why Vellform retries exactly those two 4xx and nothing else, and why Typeform pulls 429, 408, 503 and 423 onto a separate schedule — every two to three minutes for ten hours. 429 is the only code you can return that means later without also meaning broken.

Retry-After: who sends it, who honours it

Retry-After has two forms and MDN documents both — a delay in seconds (Retry-After: 120) and an HTTP date (Retry-After: Wed, 21 Oct 2015 07:28:00 GMT). It appears on 503, on 429, and on 301.

Discord is the cleanest worked example on the receiving side. Trip a Discord rate limit and you get a 429 carrying Retry-After as a header and retry_after in the body with decimal precision, alongside X-RateLimit-Remaining, X-RateLimit-Reset-After and an X-RateLimit-Bucket naming which limit you hit. Discord's docs are explicit that limits should not be hard-coded — read the headers.

The asymmetry to plan around: sending Retry-After is a request, not a contract. Published ladders are fixed schedules. Return Retry-After: 300 to a sender whose entire window is five seconds and nothing changes, because there is no attempt five minutes out for it to move. Send it anyway — it is correct, and some senders read it — but do not design around it.

The 3xx trap

Stripe's status-code table is blunt: a 302, or any other 3xx, is recorded as a delivery failure, and the fix Stripe gives is to register the URL the redirect resolves to. Slack lists too_many_redirects as one of its retry reasons and treats more than two redirects as a failure worth retrying.

Boring outage, boring cause. Someone adds trailing-slash normalisation at the edge, or an http to https redirect at the CDN, and every webhook to that path starts paying a hop it never used to. Register the final URL, and check it with curl -i -X POST — the first response line should be a 200, not a 301.

Vellform's ladder is short on purpose

The numbers

Three attempts, total. The first goes out immediately, the second about a second later, the third about four seconds after that — which puts the last attempt roughly five seconds after the first. Then it stops. 4xx responses are not retried, with the two exceptions above: 408 and 429.

  1. 1

    0s

    first attempt

  2. 2

    ~1s

    after the first wait

  3. 3

    ~5s

    last attempt

Three attempts, and the whole window closes in about five seconds.

Each request is signed with HMAC-SHA256 over the raw request body — the bytes as received, before your framework parses them. If your stack has already turned the body into an object by the time your handler runs, you cannot recompute the signature: a re-serialised object is not the same bytes. That is the most common cause of verification failing against any sender. Setup steps and a working verification snippet live on the webhooks section of the integrations page, and a form can deliver to five places — webhooks, Slack, Discord and Google Sheets delivery, plus plain email.

Why a bounded window is worth something

Ladder length is not really a fact about the sender. It is a specification for your storage.

If every duplicate that will ever exist arrives inside about five seconds, your dedupe key needs to survive about five seconds. A 60-second TTL on an in-process map, or a Redis key with EX 60, is an order of magnitude of headroom. Point Stripe's three-day window at the same endpoint and that map is wrong: you now need something that survives three days and survives your deploys — a table with a unique index, not a cache.

Line the four windows up — about 5 seconds, about 6 minutes, about 10 and a half hours, up to 3 days — and the sizing rule falls out. Your idempotency store has to outlive the longest ladder pointed at it. A short ladder does not let you be lazy; it makes the correct implementation cheap.

The honest cost

Here is what a five-second window costs, stated up front so you can design for it rather than discover it. If your endpoint is unreachable for longer than about five seconds, the deliveries sent during that gap are gone. A rolling deploy that takes twenty seconds to restore the route does not lose one delivery — it loses every submission that arrived in those twenty seconds. The integrations tab keeps a delivery log, so you can see afterwards which ones failed; what you will not find is a button that sends them again.

What a long ladder buys, and what it charges you

A long ladder buys you an overnight outage. Typeform's normal back-off runs 5 minutes, 10, 20, then 1, 2, 3 and 4 hours, so an endpoint that comes back the next morning still gets the event. That is a real capability, and a five-second window does not have it.

The price lands on your side. Ten and a half hours of ladder means ten and a half hours of idempotency: your dedupe records have to outlive a deploy, a restart and probably a cache flush. And a broken endpoint accumulates — on the throttling schedule, every two to three minutes for ten hours, one event can generate two to three hundred attempts before the sender gives up.

Two details worth reading yourself rather than taking from me. Typeform's page says it retries five times, then lists seven intervals; that inconsistency is in the published doc, and when a vendor's own numbers disagree, design for the larger one. And Typeform disables the webhook immediately on a 404 or 410 — not just that delivery, the integration. A route that 404s during a deploy is a different class of problem from one that 500s.

What this means for your endpoint

Acknowledge first, process after

Stripe tells you to return a 2xx before any logic that could time out. Slack gives you three seconds; Typeform gives you thirty. Neither budget covers writing to four systems and sending an email. So the request thread does three things and stops: verify the signature, claim the event, return 200.

ts
app.post(
  "/hooks/forms",
  express.raw({ type: "application/json" }), // keep the bytes — the HMAC is over these
  async (req, res) => {
    if (!verifySignature(req.body, req.headers)) {
      return res.status(401).end(); // wrong secret; a retry will not fix it
    }

    let event;
    try {
      event = JSON.parse(req.body.toString("utf8"));
    } catch {
      await rawLog.write(req.body); // keep what you could not parse
      return res.status(400).end(); // malformed: a retry replays the same failure
    }

    // Uniqueness enforced by the database, not by a read-then-write check:
    //   INSERT INTO deliveries (id) VALUES ($1) ON CONFLICT DO NOTHING
    const claimed = await claim(idOf(event));
    if (!claimed) return res.status(409).end(); // already have it

    res.status(200).end();   // acknowledge first
    await queue.push(event); // ...then do the work
  },
);
The whole job on the request thread. Everything slow happens after the 200.

Return 409 on a duplicate

RFC 9110 defines 409 Conflict as a request that conflicts with the current state of the server, which is precisely what a duplicate is. It is a 4xx, so on senders that stop at 4xx — Vellform among them — it ends the ladder immediately instead of letting the sender spend its remaining attempts on an event you already hold.

Read the table before generalising, though. Stripe treats every non-2xx as a failure and keeps retrying whichever 4xx you send, so against Stripe the terminal answer for a duplicate is a plain 200. Choose the response per sender.

Return 400, not 500, on a body you cannot parse

A 500 tells the sender to come back, and a body your parser cannot read will not become readable on the second attempt — so a 500 there buys you three identical log lines instead of one. RFC 9110's 400 is for malformed request syntax, which is exactly this case.

In practice the trigger is rarely a corrupt body. It is an unexpected shape — a KeyError on a field that simply was not in this payload, which is why the payload your parser receives changes shape. That deserves a 400 and an alert, not an unhandled 500. Write the raw bytes somewhere first, as in the handler above: a 400 ends the ladder, and without those bytes the delivery is unrecoverable.

One hard exception: never return a 404 from a webhook route, and never let an unmatched path fall through to one. To Typeform a 404 means delete the integration — and a 404 from a misrouted request is indistinguishable from one from an endpoint you actually removed.

Return 429 with Retry-After when you are the one overloaded

Reserve 429 for what it means. RFC 6585 defines it and says the response may include Retry-After; send the header, because it costs nothing and some senders read it. What it buys varies: on Vellform's ladder 429 is one of only two 4xx retried at all, and on Typeform it moves that delivery onto the every-two-to-three-minutes schedule. If you are down rather than throttling, 503 is more accurate and Typeform treats it the same way.

Your handler's budget is the sender's patience, not yours

Slack: three seconds. Typeform: thirty. Vellform's second attempt fires about a second after the first — and that last one has a consequence people miss. If your handler takes four seconds to answer attempt 1, attempt 2 has already arrived and is running concurrently with it. You do not have a sequence of retries. You have two threads racing on the same event.

That is why the duplicate check above is INSERT ... ON CONFLICT DO NOTHING rather than if (await seen(id)) return. A read-then-write check has a window between the read and the write, and a one-second retry interval is a machine purpose-built to find it. Make uniqueness a constraint the database enforces.

If you need a longer window than the sender gives you

Put something always-up in front

The part of your system that must survive a deploy is small: verify the signature, append to a queue or a table, return 200. No business logic, no dependency that can be mid-migration — and it should ship separately from everything it feeds. A function whose deploy is a version swap has a downtime window measured in that swap; a monolith restart is measured in tens of seconds. Against a five-second ladder, that difference is the whole outcome.

Reconcile from the CSV export

The backstop that does not depend on delivery at all: the responses exist in the form regardless of what happened to your endpoint. Export CSV for the window you lost and reconcile on the same key your handler dedupes on. A published form keeps collecting either way — responses keep collecting on the free plan at zero credits — so the export stays complete even when the credit balance is not. Not elegant. It is the difference between a bad afternoon and missing data.

Questions people actually ask

Does a webhook retry on a 404?
There is no single answer, which is the point of the table. Vellform does not — 404 is a 4xx, and only 408 and 429 are exempt. Typeform does not either, and goes further: a 404 or 410 disables the webhook immediately. Stripe does retry, treating a 404 as an ordinary delivery failure for up to three days. Whichever sender you use, never return a 404 from a webhook route.
What happens if my endpoint is down during a deploy?
On a five-second ladder, deliveries sent during the gap are lost. That is the stated cost of a bounded window. Both mitigations are architectural: put a thin always-up receiver in front of your real processing, or reconcile afterwards from the CSV export.
Are retries sent in order?
Do not assume ordering from any sender. Stripe states outright that it does not guarantee events arrive in the order they were generated, and warns against using the created timestamp to order or deduplicate them. Retries interleave with new traffic by definition: attempt 2 of an older event can land after attempt 1 of a newer one. Order by a field you control.
Can I replay a failed delivery?
Depends on the sender. Stripe has manual resend — 15 days from the Dashboard, 30 from the CLI. Vellform has no replay button and no dead-letter queue: once the three attempts are spent, the recovery path is the CSV export and reconciliation. There is a delivery log in the integrations tab, so you can see which deliveries failed — but reading it is the whole of what it offers. Better to know that before you need it than after.
Sources

Sources

Every link below was opened and checked against the sentence it is cited for.

  1. 01Typeform's retry intervals (5m, 10m, 20m, 1h, 2h, 3h, 4h), the separate 2–3 minute schedule for 429/408/503/423, the 30-second timeout, immediate disabling on 404 and 410 — and the page's own wording of five retries against seven listed intervals. Typeform — Webhooks
  2. 02The Typeform-Signature header: HMAC SHA-256 over the received payload, base64-encoded, with a sha256= prefix. Typeform — Secure your webhooks
  3. 03The up-to-three-days live-mode retry window, 3xx redirects counted as delivery failures, the advice to return 2xx before slow logic, the no-ordering guarantee and event-ID deduplication, manual resend windows, and Stripe-Signature as HMAC-SHA256. Stripe — Receive Stripe events in your webhook endpoint
  4. 04Three retries at nearly immediately, one minute and five minutes; the three-second response budget; the x-slack-retry-num and x-slack-retry-reason headers including too_many_redirects; and opting out with x-slack-no-retry: 1. Slack — The Events API
  5. 05The X-Slack-Signature header, and the HMAC-SHA256 base string of version, timestamp and raw request body joined by colons. Slack — Verifying requests from Slack
  6. 06429 responses carrying Retry-After as a header and retry_after in the body, the X-RateLimit-* headers, and the instruction not to hard-code rate limits into your app. Discord — Rate limits
  7. 07The definitions this post leans on: 400 Bad Request as malformed request syntax, 408 Request Timeout, 409 Conflict as a conflict with the current state of the server, and idempotency. RFC 9110 — HTTP Semantics
  8. 08429 Too Many Requests, and that such a response may include a Retry-After header indicating how long to wait before making a new request. RFC 6585 — Additional HTTP Status Codes
  9. 09Both header forms — delay-seconds and HTTP-date — and the status codes it accompanies: 503, 429 and 301. MDN — Retry-After

Vellform Engineering

We write about the parts of form infrastructure that only show up in production.

Webhook docs