</>CodeWithKarani

Payment Webhook Signature Fails on One Server: The Clock-Drift Check Nobody Runs

Karani GeoffreyKarani Geoffrey7 min read

The bug that eats a whole afternoon looks like this: webhook signature verification passes on your laptop, passes in staging, passes on three of the four production boxes, and fails on the fourth. Same secret. Same code. Same deploy. You rotate the secret, you re-read the algorithm, you diff the raw body byte for byte. Everything checks out, and one server still rejects every event as a forgery.

If the failure is intermittent and machine-specific rather than total, stop looking at your code. The most likely culprit is the one thing nobody thinks to check because it is not part of the application at all: the server's system clock has drifted from real time. This is common on containers without NTP, on VMs restored from snapshots, and on hosts whose time sync silently died months ago.

There is an important subtlety here that most guides get wrong, and it changes what you should check for each provider. Not every webhook signature is clock-sensitive. Stripe's is, because its signature includes a timestamp and a tolerance window. Paystack's and Flutterwave's are not, because they sign only the payload. Knowing which kind you have tells you whether clock drift can even be the cause, so let us be precise.

  • Stripe signs a timestamp plus body and rejects events outside a default 5-minute tolerance. A drifted clock breaks it directly. Fix the clock; do not widen the tolerance.
  • Paystack (HMAC-SHA512, x-paystack-signature) and Flutterwave (secret hash, verif-hash) sign only the body, so clock drift cannot break their signature. If those fail intermittently, suspect a mangled raw body, not the clock.
  • Sync every host to NTP: timedatectl set-ntp true, verify with timedatectl status.
  • On failure, log the computed and received signatures (never the secret) so you can tell a real forgery from a drift or parsing bug.

Why a drifted clock breaks Stripe but not Paystack

An HMAC signature is a hash of some input keyed by your shared secret. Clock drift can only affect verification if the current time is part of that input, either in the signed payload or in a rule your verifier applies. That is the whole mechanism, and it splits the providers cleanly.

Stripe sends a Stripe-Signature header shaped like t=1690000000,v1=<hex>. The t is the Unix timestamp when Stripe signed the event, and v1 is an HMAC-SHA256 over the string "{t}.{raw_body}". When Stripe's library verifies the event, it recomputes that HMAC and checks that t is within a tolerance of your server's current time, five minutes by default. If your server clock is ten minutes fast or slow, the HMAC matches perfectly but the timestamp check fails, and the library raises an error. That is drift breaking a genuinely time-bound signature.

Paystack computes HMAC-SHA512 over the raw request body only, keyed by your secret key, and sends it as x-paystack-signature. No timestamp is in the signed input and no time check is applied. Flutterwave is even simpler: you set a secret hash in the dashboard, and it sends that value back to you in the verif-hash header for you to compare. Neither involves the clock. So if Paystack or Flutterwave verification fails on one box and not another, the clock is a red herring, and you should look at what is corrupting the body on that box.

Stripe: time is part of the check signed input = t . raw_body verifier also checks: | server_now - t | < 5 min tolerance clock drift > 5 min = rejected Paystack / Flutterwave: time is not involved signed input = raw_body only no timestamp, no tolerance window clock drift cannot break this If Paystack/Flutterwave fail intermittently, the raw body is being altered, not the clock. If Stripe fails intermittently across a fleet, check NTP on the failing host first.
Match the symptom to the mechanism before you touch a single line of code.

The exact failures you will see

Stripe's library raises a signature error whose message names the timestamp when drift is the cause:

stripe.error.SignatureVerificationError: Timestamp outside the tolerance zone

That wording is the tell. It is not "no signatures found matching" (which means a body or secret problem); it explicitly blames the timestamp, which means the clock. Paystack and Flutterwave give you no such gift; a failed comparison there is just a boolean that came back false, which is exactly why people misattribute it.

The fix, in steps

Step 1: Confirm the clock is actually drifting

On the suspect host, check time sync status:

timedatectl status

You want to see System clock synchronized: yes and NTP service: active. If either says no or inactive, the machine is keeping its own time and will drift. Compare it against real time to see how far:

date -u; curl -sI https://www.cloudflare.com | grep -i '^date:'

The Date: header from any well-run public server is real UTC. If your date -u is more than a minute or two off it, you have found the problem, and if it is more than five minutes off, that alone explains every Stripe rejection on this box.

Step 2: Turn on NTP and let it correct

sudo timedatectl set-ntp true

This enables systemd-timesyncd (or hands off to chrony/ntpd if installed) to sync against the configured NTP servers. Give it a few seconds, then re-run timedatectl status and confirm System clock synchronized: yes. If you prefer chrony, which is the better choice on a busy fleet, install it and check chronyc tracking, where the System time offset line should read close to zero.

Step 3: Fix containers, which do not have their own clock

This is where most people get bitten. A container shares the host kernel's clock; it does not run its own NTP. So timedatectl set-ntp true inside a container does nothing useful. The correct fix is to sync the host. If your app runs in Docker or Kubernetes, the node must have NTP, and then every container on it inherits correct time for free. A container whose host clock is wrong will fail Stripe verification no matter what you put in the image.

# Run on the Docker/Kubernetes NODE, not inside the container
sudo timedatectl set-ntp true
timedatectl status

Point NTP at your cloud provider's time service where one exists (AWS, GCP and Azure each run a local NTP endpoint that is faster and more reliable than reaching pool.ntp.org across the public internet), otherwise pool.ntp.org is a fine default.

Step 4: Do not widen Stripe's tolerance to hide it

Stripe's constructEvent lets you pass a larger tolerance, and it is tempting to bump it to an hour and move on. Resist. The tolerance exists to stop replay attacks; widening it to paper over a broken clock re-opens that window on every server, and you still have a machine whose time is wrong, which will break other things (logs, token expiry, TLS) later. Fix the clock. Leave the tolerance at its default.

Step 5: Log the right thing on failure

When verification fails, log enough to diagnose without ever logging the secret. For Stripe, log the parsed t and your server's current time so drift is obvious at a glance. For Paystack and Flutterwave, log the computed signature and the received one, side by side:

import hmac, hashlib, logging

def verify_paystack(raw_body: bytes, received_sig: str, secret: str) -> bool:
    computed = hmac.new(secret.encode(), raw_body, hashlib.sha512).hexdigest()
    ok = hmac.compare_digest(computed, received_sig)
    if not ok:
        logging.warning("paystack sig mismatch computed=%s received=%s len=%d",
                        computed, received_sig, len(raw_body))
    return ok

If the two signatures differ, it is a real forgery or a mangled body, not the clock. If they are identical yet Stripe still rejects, it is the timestamp, and therefore the clock. That one log line ends the guessing. Note the use of hmac.compare_digest, a constant-time comparison, rather than ==. The deeper reason your raw body matters so much is covered in Your Webhook Endpoint Trusts Anyone Who Can curl It and, for the framework-specific body-parser trap, Webhook Signature Verification Fails Randomly.

Verification

After syncing, the offset should be tiny and stable. Confirm it directly:

timedatectl status | grep -E 'synchronized|NTP service'

Expected: System clock synchronized: yes and an active NTP service on every host in the fleet, not just the one that was failing. Then replay a Stripe event to the previously-failing box (the Stripe CLI's stripe trigger or a resend from the dashboard) and confirm it now verifies. For Paystack and Flutterwave, a passing verification after a code change confirms the body handling, since the clock was never their problem.

What people get wrong

Rotating the secret first. A wrong secret fails everywhere, on every server, every time. If even one server succeeds with the current secret, the secret is correct and rotating it wastes an outage window. Machine-specific intermittency almost never means the secret.

Blaming clock drift for Paystack failures. Because this article exists, someone will now check NTP when their Paystack webhook fails. Paystack's signature has no timestamp, so a correct clock cannot fix it and a wrong clock cannot break it. For Paystack, an intermittent failure is a body that got re-serialised, gzipped, or trimmed by a proxy before your HMAC saw it.

Testing only against the sandbox secret. Sandbox and live use different secrets. Verification that passes in the sandbox proves nothing about production if you never confirmed the live secret is wired in. Before go-live, verify against the production-configured secret at least once, not just the test one. If your sandbox itself is flaky, Testing Payment Integrations When the Sandbox Is Flaky covers recorded fixtures.

When it is still broken

If the clock is correct and Stripe still rejects, verify you are passing the truly raw request body to constructEvent and not a re-serialised JSON string; a single re-encoded field changes the HMAC. If Paystack or Flutterwave still fail with a synced clock, capture the exact bytes your handler receives versus what the provider sent, because a proxy or body-parser middleware is almost certainly altering them. And confirm you are comparing against the right header name for each provider, since mixing them up (x-paystack-signature versus verif-hash) produces a permanent, not intermittent, failure. Stripe's tolerance behaviour is documented in its webhook signature reference.

Frequently asked questions

Can server clock drift really break Stripe webhook verification?
Yes. Stripe's signature includes a timestamp and its libraries reject events whose timestamp is more than about five minutes from your server's current time. If a server's clock has drifted beyond that tolerance, the HMAC still matches but the timestamp check fails, so verification breaks on that host while others pass. Fix the clock with NTP rather than widening the tolerance.
Does clock drift break Paystack or Flutterwave webhooks too?
No. Paystack signs only the request body with HMAC-SHA512 and Flutterwave sends back a fixed secret hash, so neither signature involves a timestamp or a time check. If those verifications fail intermittently, the raw request body is being altered by a proxy or body-parser before your check, not the clock.
How do I check if my server clock has drifted?
Run timedatectl status and confirm it shows System clock synchronized: yes and an active NTP service. Compare date -u against the Date header from a well-run public server; if you are more than a couple of minutes off, the clock is drifting. Enable sync with sudo timedatectl set-ntp true.
My app runs in Docker, how do I fix the container clock?
You fix the host, not the container. Containers share the host kernel's clock and do not run their own NTP, so enabling NTP inside the container does nothing. Sync the Docker or Kubernetes node with timedatectl set-ntp true and every container on it inherits correct time.
#Stripe#Paystack#Flutterwave#Webhooks#NTP
Keep reading

Related articles