CanHook
Features Pricing Docs FAQ Blog Log in Get started free

What a 5xx From Your Webhook Handler Actually Triggers

By The CanHook Team · September 10, 2026 · 950 words
In short

Any non-2xx status, a connection timeout, or a TLS failure counts as a delivery failure. The sender (or a relay forwarding on your behalf) retries on a backoff schedule instead of giving up, so your handler will see the same event again — sometimes hours later.

Return anything other than a 2xx from a webhook handler and the request is not over. The sender treats it as a failed delivery and schedules another attempt. That single detail changes how you should write the handler: it will run again, on the same event, on a schedule you don't control.

What actually counts as a failure

It is not just a 500. Any status code outside the 200–299 range counts as a failed delivery to most senders, including a 400 from your own validation code, a 401 from misconfigured auth middleware, and a 404 from a route typo. A connection timeout, a TLS handshake failure, or the sender's own read timeout all count too — there is no response to inspect, so it is treated the same as an explicit failure.

This matters because a 4xx often means "this request will never succeed" (a malformed payload, an expired signature), but the sender can't tell your intentional rejection from a bug in your route. It retries either way, which is why verifying the signature correctly the first time matters — a false-negative rejection just costs you the same failed delivery, repeated.

How CanHook's relay worker treats a non-2xx

When a relay rule forwards a captured request to your destination, the outcome is judged the same way: anything outside 200–299, or a transport error, is a failure. RelayService::attempt() then schedules a retry with fixed exponential backoff — 60 seconds, 5 minutes, 25 minutes, then 2 hours — up to the rule's retry_count (3 by default, configurable 0–10). Past the last configured attempt, the delivery is marked failed permanently; nothing after that is automatic.

AttemptWait before this attempt
1immediate (inline, right after the source request completes)
260s
35m
425m
5+2h (repeats for any attempt beyond the 4th)

The first attempt runs inline, right after the inbound request's own response has already been sent — the sender's 200 is never held up waiting on your destination. Every subsequent attempt is picked up by the relay worker, which runs once a minute and processes Business-tier deliveries first when the queue is backed up; that changes processing order, not the number of attempts a rule gets.

Watching it happen

You don't need a real destination to see the schedule above in practice. Point a relay rule at a handler that always fails, capture one request through your catch URL, and watch the delivery log fill in over the next couple of hours:

// server.js — fails on purpose so you can watch the retry schedule
const express = require('express');
const app = express();
app.use(express.json());

app.post('/relay-target', (req, res) => {
  console.log(new Date().toISOString(), 'attempt received');
  res.status(500).send('simulated failure');
});

app.listen(4000);

Each attempt lands as its own row against the delivery, with the response status, a truncated response body, and duration — that log is what tells you whether a destination is down, slow, or rejecting the payload outright, without you adding any logging of your own.

Writing a handler that survives being called twice

Because a retry means the exact same event arrives again, the handler has to be safe to run more than once. A few habits make that true in practice:

  • Store an idempotency key (the provider's event id, or a hash of the raw body) before doing anything with side effects, and check it first on every call.
  • Do the expensive or external work (charging a card, sending an email) only after that check passes — never before it.
  • Return the 2xx only once your handler has actually committed the write. Returning 200 early and finishing the work in the background means a crash after the response leaves you with a delivery marked "successful" that never actually completed.
  • If a failure is permanent (bad payload, expired signature), return a 4xx deliberately so it's visible in logs as a rejection rather than a timeout — the sender still retries it the same way, but you'll be able to tell the two apart later.

This is the other half of the story covered in handling duplicate deliveries: retries are the mechanism, duplicates are the consequence, and an idempotency key is what makes both survivable.

What a non-2xx does not do

It does not make the sender stop trying sooner. It does not escalate to a different destination. And on CanHook specifically, it does not affect your captures or other endpoints — a stuck relay rule fails in isolation from the catch URL that fed it, and from every other rule on the same endpoint. There's also no automatic dead-letter export once a delivery exhausts its attempts; the failed row stays in the delivery log for you to inspect or retry manually, it isn't queued anywhere else on its own.

Set the retry count for what you're forwarding to

The default of 3 attempts (roughly 6 minutes end to end, given the first retry lands 60 seconds after the inline attempt) is a reasonable default for a service you expect to be up. For a destination you know goes through periodic maintenance windows, raising retry_count toward the 10-attempt ceiling buys you the full 60s/300s/1500s/7200s ladder plus repeats of the final 2-hour wait, at the cost of a delivery that can still be "in flight" many hours after the source event happened. For a destination that fails fast and predictably (bad auth, wrong URL), leave it low so a dead rule doesn't sit in the retry queue for half a day before you notice it in the log.

None of this replaces reading your own logs. The delivery log tells you what happened on CanHook's side of the request; your handler's own logs are still the only record of what happened once the request reached it.

Frequently asked questions

Does a 400 or 401 get retried the same way as a 500?

Yes. Both CanHook's relay worker and most webhook senders treat any status outside 200-299 as a failed delivery and retry it, regardless of whether the failure was your validation code or an actual server error.

What's CanHook's default retry schedule for a failing relay rule?

60 seconds after the first attempt, then 5 minutes, then 25 minutes, then 2 hours - and 2 hours again for any attempt past the fourth, up to the rule's configured retry_count (default 3, adjustable 0-10).

Does a connection timeout count as a failure the same as a 5xx?

Yes. A timeout, a TLS handshake failure, or any transport error where no response comes back is treated the same as an explicit non-2xx status - it schedules a retry on the same backoff schedule.

Do Business-plan relay rules get more retry attempts?

No. Business-tier deliveries are processed first when the retry queue is backed up, which can mean a shorter effective delay under load, but the number of attempts still comes from the rule's own retry_count setting.

What happens after a relay rule exhausts all its retries?

The delivery is marked failed permanently in the delivery log. There's no automatic dead-letter queue or export - you retry it manually from the dashboard once the destination is fixed, or accept the loss.

Should my webhook handler return 200 before finishing the work?

Only if the work that matters (the database write, the idempotency check) already happened. Returning 200 first and finishing in the background risks marking a delivery successful that never actually completed.