Why your webhook is rejected after five minutes
A BuildBase delivery older than five minutes fails verification before the signature is ever compared. Why the max age exists, and the four things that trip it legitimately.
In short
A delivery is refused when the x-buildbase-timestamp header sits more than 300 seconds from your clock, and it is your own handler doing the refusing. parseWebhookEvent checks the age before it compares the signature, and returns null for both. Backlog causes this far more often than clock drift does.
The rule is flat: a delivery whose x-buildbase-timestamp sits more than five
minutes from your clock is refused. Not slowed, not flagged. Refused, before the
signature is ever compared.
And the refusal is yours. Nothing on our side expires anything - the sending
service posts the delivery and forgets about it. The five minutes is enforced
inside verifyWebhookSignature in @buildbase/sdk, running in your process, on
your host, against your clock. Which is why the error string you are looking at
almost certainly reads Invalid signature, and why grepping for "webhook
timestamp too old" turns up forum threads instead of anything we print.
parseWebhookEvent returns null for a stale delivery and null for a forged
one, and the return value cannot tell you which.
Why a maximum age exists at all
An HMAC signature proves who sent something. It does not prove when, and it never stops being true.
Work through what that means. We sign {timestamp}.{body} with your endpoint's
secret and put the result in x-buildbase-signature. That signature is valid
the second it is generated and it is equally valid a year later, because nothing
in the math decays. So anyone who ends up holding one valid delivery - a
reverse proxy access log that captured headers, an error reporter that attached
the full request, a laptop with a tunnel history - holds something they can POST
at your endpoint again. Byte for byte. It verifies every time.
Our own inbound receiver spells this out. The Mailgun handler at
server/src/services/email/providers/mailgun-webhook.ts lists three checks and
says why the second one is not optional:
The timestamp, within a window. Without it a single captured delivery stays valid forever, because the signature over it never expires.
A maximum age converts "forever" into a bounded window. Five minutes is the window. The timestamp cannot simply be advanced by whoever captured the delivery, because it is inside the signed string, not beside it - move the timestamp and the HMAC no longer matches.
Here is the part that should bother you slightly. Mailgun's deliveries carry a per-delivery token, so our receiver records the token and refuses a repeat inside the window. Ours do not. Our documented delivery contract says deliveries carry no unique event ID, and tells you to deduplicate on a hash of the raw request body. So for a BuildBase webhook, the age window is not one replay defence among several. It is the replay defence. That is the honest reason the default is tight, and the reason I would push back on anyone widening it to an hour because retries were failing.
Key takeaway
The five minutes is not about freshness of data. It is the only thing bounding how long a captured delivery stays replayable, because deliveries carry no nonce and no event ID.
What the check compares, exactly
Two numbers, and it is worth being precise about which two, because the wrong mental model here sends people to chrony when the problem is their own queue.
The number that ships is maxAgeSeconds, defaulting to 300, declared in the
SDK's own type. The number our marketing and docs quote is
SESSION_DEFAULTS.webhookMaxAge, the string '5 minutes', in
packages/shared/src/constants/platform-stats.ts. Same value, two homes.
Transcribed from the shipped bundle, the check is:
// inside verifyWebhookSignature, @buildbase/sdk
if (maxAgeSeconds > 0) {
const sent = parseInt(timestamp, 10);
if (isNaN(sent)) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - sent) > maxAgeSeconds)
return false;
}Three things fall out of those lines.
It compares the signature timestamp against Date.now() at the moment
verification runs, not against the time your server accepted the connection.
Those are different clocks in any handler that does work before it verifies.
It uses Math.abs, so the window is symmetric. A delivery stamped in your
future fails exactly as hard as one stamped in your past. That is not an
accident and it is the single most useful diagnostic signal available to you -
more on that below.
And it runs before the HMAC comparison. A stale delivery never reaches the
signature check, which is why a perfectly signed payload comes back as
Invalid signature and sends people off to re-check a secret that was never
wrong.
Our sending side does its half of this in
server/src/services/webhooks/webhook-dispatch.service.ts:
const timestamp = Math.floor(Date.now() / 1000);One stamp, taken when the event is dispatched, before the job is even enqueued. Carry that fact into everything below.
Where it trips legitimately
Before you start
You have an endpoint in the console, you are reading its delivery log, and the deliveries are arriving. If they are not arriving at all, you are looking at throttling or an auto-disabled endpoint, which is a different page of the log.
Your handler queues deliveries behind slow work. The clock keeps running
while a request sits in your runtime. We allow 15 seconds per attempt before
giving up, with a delivery worker concurrency of 10, so a receiver that takes
the full 15 seconds caps the whole instance at about 40 attempts a minute -
and yours is not the only endpoint in the queue. Meanwhile, on your side, every
second a request spends waiting on a database pool, a cold Lambda or an
event-loop block is a second of the 300 spent before parseWebhookEvent is
called. Verify first, answer 2xx, then do the work. That ordering is not a style
preference here.
A retry storm after an outage. This is the one that surprises people.
Retries do not get a fresh timestamp. The value stamped at dispatch is stored on
the job and reused for the signed string and the header on every attempt - the
delivery worker reads job.data.timestamp, it does not re-stamp. Four retries
land at roughly 10s, 20s, 40s and 80s, so the last attempt is signed with a
timestamp about 2.5 minutes older than the moment it arrives. That fits inside
300 seconds with room to spare, which is why it normally goes unnoticed. It
stops fitting when the attempt also waits in a queue.
Which brings up the arithmetic worth knowing. server/src/constants/webhook-limits.ts
lets one organization enqueue 600 deliveries a minute, and limits the delivery
worker to starting 300 a minute instance-wide. Those two numbers do not match on
purpose - the first bounds a tenant, the second protects everyone else. But a
sustained burst above 300 a minute means the backlog grows while every job in it
carries a timestamp that keeps aging. A campaign is the usual shape for this,
since email.sent fires once per recipient.
A paused local tunnel. Every developer hits this one. The tunnel drops, your laptop sleeps, you come back, and a run of deliveries lands at once carrying timestamps from before lunch. Every one of them fails, and every one of them fails correctly. Nothing is broken. Do not go fixing it.
Genuine clock drift on the receiving host. Real, and rarer than its reputation. Containers that never run an NTP client, a VM resumed from a snapshot, a bare-metal box nobody has looked at since it was racked.
Clock skew is the answer people reach for, and usually it is not it
The reasoning is understandable. The word "timestamp" is in the failure, and
clock skew is the famous timestamp bug, so the investigation starts at timedatectl
and burns an afternoon there. But drift big enough to break a 300-second window
means a host five minutes wrong, and a host five minutes wrong usually has
louder symptoms than webhooks - expiring tokens, certificate complaints, log
lines out of order.
The test takes one line, and it hinges on the Math.abs in the SDK. That
absolute value throws away the sign. Keep the sign yourself:
import { parseWebhookEvent } from '@buildbase/sdk';
export async function POST(req: Request) {
const body = await req.text();
const sentAt = Number(req.headers.get('x-buildbase-timestamp'));
// Signed, not absolute. The sign is the whole diagnosis.
const delta = Math.floor(Date.now() / 1000) - sentAt;
console.log('webhook delta seconds', delta);
const event = parseWebhookEvent({
body,
signature: req.headers.get('x-buildbase-signature'),
timestamp: req.headers.get('x-buildbase-timestamp'),
secret: process.env.WEBHOOK_SECRET!,
maxAgeSeconds: 300, // the default, named so the rejection is greppable
});
if (!event) {
return Response.json({ error: 'stale or unsigned' }, { status: 401 });
}
await enqueueForProcessing(event); // answer first, work later
return Response.json({ received: true });
}I have not executed this handler as written; it is the shape the SDK's own type declaration requires, with the age check named rather than left to the default.
Now read the deltas:
- Negative delta. The delivery is stamped in your future. Backlog cannot do that - queueing only ever makes a delivery older. A negative delta is clock skew, every time, no further investigation needed.
- Positive, large, and roughly constant at 3am and at peak. Also skew. A fixed offset does not care how busy you are.
- Near zero when quiet, climbing when busy. Backlog. Either ours or yours,
and the console tells you which: each delivery-log row stores the exact
requestBodywe sent, timestamp included, alongside the row's own creation time. Subtract them and you have our share of the delay. Whatever is left over is yours. Delivery logs are kept for 30 days.
What not to do about it
Do not reach for maxAgeSeconds: 0. It is a real option and it does exactly
what it says, which is to hand back the unbounded replay window the check exists
to close. If your deltas genuinely run long for a reason you have measured,
raise the number deliberately and pair it with body-hash deduplication, the same
way our Mailgun receiver pairs a 15-minute window with a recorded per-delivery
token.
And do not let a rejected delivery be a silent one. A refused
subscription.trial_started is a trial your app never learned about, which
matters more than it sounds once
the gate is checking for an active subscription.
A refused credit.granted is a balance that never moved. Log the delta, alert
on null, and treat a 401 from your own webhook route as an incident rather
than noise.
The flip side of retries reusing one timestamp is that a retried delivery is byte-identical to the original, so your handler has to be safe to run twice regardless of any of this. The discipline is the same one the credit debit path and server-side quota enforcement already need: an idempotency key, or a hash you have seen before.
Install
npm i @buildbase/sdk