Delivery and retries
What counts as a delivered webhook, which failures Knock retries and on what schedule, what pausing or changing a webhook does to pending deliveries, and how to handle duplicates, ordering and debugging on your side.
Knock sends each event to your webhook's URL as a POST and keeps trying until your endpoint accepts it or the retry schedule runs out. Delivery is at least once: an event can arrive more than once, and events can arrive out of order. This page covers what Knock expects from your endpoint, what it does when a delivery fails, and how to build a receiver that handles both safely.
What counts as delivered
A delivery succeeds when your endpoint answers with any 2xx status within 5 seconds. Anything else is a failed attempt: a different status, a slow answer, or no answer at all. Whether Knock tries again depends on how it failed (see Which failures are retried).
- 5-second timeout. The 5 seconds cover the whole attempt: resolving your host, connecting, TLS, sending the body and receiving your status line. Every attempt has the same limit, retries, pings and URL tests included.
- Redirects aren't followed. A
3xxis your endpoint's answer, and it isn't retried. Register the URL that answers directly. - The response body is never read. Only the status code counts, plus the
Retry-Afterheader on a429or503. Knock closes the connection as soon as your status line and headers arrive, so whatever you put in the body is ignored. Leave it empty.
Answer as soon as you've stored the event, and do the real work afterwards. See Respond fast, then process.
What stays the same from one attempt of an event to the next, and what changes:
- The same on every attempt:
webhook-id(the event ID) andknock-webhook-id(the webhook this delivery is for). - New on every attempt:
webhook-timestamp(when this attempt was sent). - The same body on every attempt: Knock builds the body once, when it accepts the event, so a retry never carries newer data than the first attempt.
- Only when the webhook has a secret token:
knock-webhook-token, that webhook's token exactly as you set it. - Read again for every attempt: the webhook's URL and its secret token. A retry made after you change the URL goes to the new URL, and one made after you change the token carries the new token.
Verify webhooks lists every header and how to check the token. Don't order events by webhook-timestamp: it's when the attempt was sent, not when the event happened. Use the body's occurredAt (see Handle events out of order).
Which failures are retried
| Your endpoint's result | Retried? | Notes |
|---|---|---|
2xx | No, delivered | The delivery is complete. |
| No response within 5 seconds | Yes | Counts as a timeout. |
| Connection, DNS or TLS error | Yes | For example, the host doesn't resolve or the connection is refused. |
| Your host resolves to an address that isn't public | No | Knock checks the address before every attempt, and doesn't send the request (blocked_destination). |
408, 429 | Yes | On 429, Knock honors Retry-After. |
500 to 599 | Yes | On 503, Knock honors Retry-After. |
401, 403, 404 | Yes | Common while you deploy a new endpoint or change your secret token, so Knock gives you time to fix them. |
410 | No | The delivery stops. |
Any other 4xx, such as 400, 405, 409 or 422 | No | The delivery stops. |
Any 3xx | No | Knock doesn't follow redirects. Register the final URL instead. |
Return 401, not 400, when a delivery doesn't verify
A webhook's new secret token takes effect on its very next attempt. If your endpoint still holds the old value, answer 401 or 403: Knock retries those, so the event gets through once your endpoint has the new one. A 400 stops the delivery for good. To change the token without rejecting any attempt, accept both the old and the new value before you set the new one (see Rotate the token).
A 410 stops only that event's delivery to that webhook. The webhook stays, and later events are still sent to it: pause or delete the webhook to stop them.
Retry schedule
Knock tries the first time right away, then waits longer between each retry. A delivery gets up to 10 attempts over about 21 hours:
| Attempt | Wait before this attempt (up to 20% shorter) | Time since the first attempt (approx.) |
|---|---|---|
| 1 | None | 0 |
| 2 | 30 seconds | 30 seconds |
| 3 | 1.5 minutes | 2 minutes |
| 4 | 4.5 minutes | 6.5 minutes |
| 5 | 13.5 minutes | 20 minutes |
| 6 | 40.5 minutes | About 1 hour |
| 7 | 2 hours | About 3 hours |
| 8 | 6 hours | About 9 hours |
| 9 | 6 hours | About 15 hours |
| 10 | 6 hours | About 21 hours |
Times are approximate: jitter can shorten each wait by up to 20%, so retries from many deliveries don't all arrive at once.
Retry-After
When your endpoint answers 429 or 503 with a Retry-After header, Knock waits at least that long, up to 6 hours, before the next attempt: the header can lengthen the scheduled wait, never shorten it. Both forms work, a number of seconds or an HTTP date. The attempt still counts toward the 10, and the 72-hour limit still applies. Knock ignores Retry-After on any other status. Send it when you're shedding load or under maintenance, so Knock comes back when you're ready.
HTTP/1.1 503 Service Unavailable
Retry-After: 120The 72-hour limit
Knock never retries an event more than 72 hours after it accepted it. An attempt that would fall later than that isn't sent, and the delivery stops. The schedule above finishes well inside this limit.
Pause, change or delete a webhook
Changing a webhook changes what happens to its deliveries that aren't sent yet:
- Pause it (
"status": "paused"withPATCH /v1/webhooks/{webhookId}). Knock stops deliveries to it and drops every delivery not sent yet, retries included: they're never sent, even after you resume it. A request already in flight still completes. You can still ping a paused webhook. - Resume it (
"status": "active"). Knock sends it the events it records from then on. An event from the pause that reaches Knock late can still be sent: itsoccurredAtshows when it happened. - Remove an event type from its
events. Knock stops sending that type to it, retries included. - Change its URL or secret token. Events already accepted keep going to the webhook: their retries go to the new URL and carry the new token.
- Delete it. Knock stops sending to it at once, including retries already scheduled.
A webhook created with "status": "paused" gets nothing until you set it to active. Deliveries Knock drops this way count as cancelled in the delivery stats.
When deliveries keep failing
When the last attempt fails, or a response isn't retried, Knock stops trying to deliver that event to that webhook. You can't replay a delivery. To catch up after an outage, read the current state from the API: GET /v1/meetings for meetings, and GET /v1/enrichment-requests/{enrichRequestId} for each enrichment request you haven't heard about.
Knock keeps the record of each delivery, every attempt and its result, for 3 days, the same as the 72-hour limit, and then deletes it. To find out why a delivery failed, contact Knock support with a webhook-id from your logs within those 3 days.
Deliveries to your other webhooks, including deliveries of the same event to other URLs, aren't affected: each webhook has its own retries.
To find out why an endpoint is failing, ping it. To stop deliveries to an endpoint for a while, pause the webhook; to stop them for good, delete it. Either is better than letting deliveries fail.
Knock doesn't pause or disable a webhook whose deliveries keep failing, and doesn't notify you. Watch its delivery stats and your endpoint's own error rate, and ping the webhook after an outage.
Watch delivery stats
GET /v1/webhooks/stats counts the delivery attempts of the last 24 hours (window=24h, the default) or 3 days (window=3d) by UTC hour, for one webhook (webhookId) or every webhook of your account. Pings and URL tests aren't counted, and the numbers are up to about 60 seconds behind. Get delivery stats lists every field.
A growing retrying count means your endpoint is failing now and Knock is still trying. failed counts events that webhook won't get.
Handle duplicates
Delivery is at least once, so the same event can reach your endpoint more than once. That happens when, for example, your endpoint processed an event but its 2xx didn't reach Knock in time, so Knock retried.
Every attempt of an event carries the same webhook-id, so make your receiver idempotent on it. A dependable way is an inbox table with a unique key: insert the event, ignore the insert if the key already exists, then answer 2xx either way.
Keep the IDs of the events you've handled for longer than 3 days. Knock's own record of a delivery lasts 3 days (see When deliveries keep failing), so an event Knock sends again after that, for example after an incident on Knock's side, arrives with the same webhook-id and only your inbox recognizes it as a duplicate.
CREATE TABLE knock_webhook_inbox (
event_id text PRIMARY KEY,
type text NOT NULL,
occurred_at timestamptz NOT NULL,
payload jsonb NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz
);The receivers below check the secret token in constant time, store the event once and answer right away. Set KNOCK_WEBHOOK_TOKEN to the secret token you set on the webhook, as secretToken on POST /v1/webhooks or PATCH /v1/webhooks/{webhookId} (see Verify webhooks).
// npm install express pg
import crypto from 'node:crypto';
import express from 'express';
import pg from 'pg';
const db = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const expected = crypto.createHash('sha256').update(process.env.KNOCK_WEBHOOK_TOKEN).digest();
const app = express();
function tokenMatches(received) {
if (typeof received !== 'string') return false;
return crypto.timingSafeEqual(crypto.createHash('sha256').update(received).digest(), expected);
}
app.post('/webhooks/knock', express.json(), async (req, res) => {
if (!tokenMatches(req.get('knock-webhook-token'))) {
return res.status(401).end(); // Retried, in case you're changing the token.
}
const event = req.body;
try {
await db.query(
`INSERT INTO knock_webhook_inbox (event_id, type, occurred_at, payload)
VALUES ($1, $2, $3, $4)
ON CONFLICT (event_id) DO NOTHING`,
[req.get('webhook-id'), event.type, event.occurredAt, event],
);
} catch (error) {
console.error('Could not store Knock webhook', error);
return res.status(500).end(); // Retried.
}
res.status(200).end(); // Stored now, or already stored by an earlier attempt.
});
app.listen(3000);A worker then reads rows where processed_at is null, does the work and sets processed_at. If the worker can crash halfway, make each step safe to repeat too, for example by upserting rather than inserting.
Several URLs for one event type. The webhook-id is the same for every webhook that receives the event. If two of your webhooks receive the same event type and feed the same inbox, keying on webhook-id alone processes the event once. If each webhook should process it separately, key on webhook-id and knock-webhook-id together. Use knock-webhook-id to route and deduplicate, never as proof of where a request came from: only the secret token is.
Handle events out of order
Knock doesn't guarantee order. Retries, and events that happen close together, can arrive in any order: a meeting.rescheduled can arrive before the meeting.booked for the same meeting.
Order by the body's occurredAt, when the event happened in Knock, and never let an older event overwrite newer state. For example, keep the time of the last event you applied to each meeting, and only apply a later one:
INSERT INTO meetings (meeting_id, start_at, end_at, last_event_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (meeting_id) DO UPDATE
SET start_at = EXCLUDED.start_at,
end_at = EXCLUDED.end_at,
last_event_at = EXCLUDED.last_event_at
WHERE meetings.last_event_at < EXCLUDED.last_event_at;Here $1 to $3 are data.meetingId, data.startAt and data.endAt, and $4 is the event's occurredAt. An older event that arrives late changes nothing.
Best practices
Respond fast, then process
- Answer within 5 seconds. Check the secret token, store the event and return
2xxwith an empty body. Anything slower counts as a failed attempt and is retried, even if your work later succeeds. - Queue the work. Do the processing in a background job or worker, not in the request. Calls to your CRM or other APIs can be slow, and a retry that arrives while the first attempt is still working creates a duplicate.
- Be idempotent. Deduplicate on
webhook-id, and make the processing itself safe to run twice. See Handle duplicates.
Answer with the status you mean
2xxonce the event is stored, including for a duplicate you've already stored and forwebhook.ping.401or403when the secret token doesn't match, so a delivery sent while you switch to a new token is retried.429or503withRetry-Afterwhen you need Knock to back off.5xxfor your own temporary failures, such as a database that's down.- Avoid other
4xxand3xx. Knock doesn't retry them, so the event is lost for that webhook.
Network requirements
Your endpoint has to be reachable from the public internet over HTTPS on port 443. The full rules Knock checks when you create or change a webhook, or test a URL, are under URL requirements.
Knock doesn't follow 3xx responses, and doesn't retry them. Register the URL that answers directly, including the right host and trailing slash. Every attempt has 5 seconds, DNS lookup included, to get your status line back, and Knock never reads the response body.
Deliveries arrive with content-type: application/json and user-agent: Knock-Webhooks/1. A body is at most 32 KiB. If a firewall or WAF sits in front of your endpoint, allow requests with that user agent through to it. Don't treat the user agent as proof that a request came from Knock: anyone can send it, so always check the secret token.
Ping a webhook to debug
POST /v1/webhooks/{webhookId}/ping sends a webhook.ping event to that webhook's URL, with its secret token, and reports your endpoint's answer, so you can test the full path (network, TLS, token check and response) without waiting for a real event.
To check an endpoint before you create its webhook, POST /v1/webhooks/test sends the same webhook.ping to a url you pass, with the optional secretToken you pass (Knock doesn't keep it). There's no webhook yet, so the knock-webhook-id header and data.webhookId are 00000000-0000-0000-0000-000000000000.
Both are a single attempt with a 5-second timeout, are never retried, don't affect deliveries of real events and aren't counted in the delivery stats. You can ping each webhook once every 10 seconds, and your account can test a URL once every 10 seconds: retryAfterSeconds in the result says how long to wait before the next one.
What the result tells you
| Result | Likely cause | What to do |
|---|---|---|
delivered: true | Your endpoint is reachable and accepted the event. | Nothing. Real events take the same path. |
statusCode: 401 or 403 | Your endpoint rejected the secret token. | Check that it compares against the token you set last on that webhook, or the secretToken you passed to the URL test. See Verify webhooks. |
statusCode: 404 | The path isn't deployed, or the URL has a typo. | Compare the webhook's url with your route. |
errorCode: redirect | Your server redirected, for example to another host or to add a trailing slash. Knock doesn't follow redirects. | Change the webhook's url to the final URL. |
statusCode in 500 to 599 | Your handler failed. | Check your logs for the eventId. |
statusCode: null | No HTTP response: errorCode is timeout, connection, dns or tls. | Check DNS, your certificate and that port 443 is open to the internet. |
errorCode: blocked_destination | The host resolves to an address that isn't public. | Point the host's DNS at a public address. |
durationMs close to 5000 | Your endpoint is slow to answer. | Store the event and answer before you process it. |