e-bon
e-bon.ro
Troubleshooting

HMAC signature mismatch

Why `X-EBon-Signature` verification fails on your endpoint — clock skew, wrong secret after rotation, and body re-serialization gotchas.

When your endpoint computes a different HMAC than what e-bon sent in X-EBon-Signature, almost every cause is one of three: you re-serialized the JSON body before hashing, you are using the previous secret after a rotation, or you are using the wrong secret altogether (e.g. the test webhook's secret on the production webhook).

There is no error code on the e-bon side for this — your endpoint is the one that detects the mismatch and responds with a non-2xx. e-bon then treats the response as a delivery failure and retries per the schedule in Webhook delivery failures. On your side, log the computed digest and the received X-EBon-Signature so you can compare them byte-for-byte.

Likely causes

  • Re-serialized JSON body — you parsed the request as JSON and then re-stringified it before hashing. Different serializers reorder keys, change whitespace and reformat numbers; the hash will differ. The signature is over the raw bytes of the request body.
  • Wrong secret after rotationPOST /api/v1/org/webhooks/{id}/rotate-secret returns a new whsec_… value once. If you rotated but did not update the secret stored by your endpoint, every subsequent delivery will fail verification.
  • Wrong secret for the wrong webhook — common when copy-pasting the secret of the test webhook into the production endpoint, or vice versa. Each subscription has its own secret.
  • Wrong algorithm or encoding — the algorithm is HMAC SHA-256, the digest is lowercase hex, and the header value is the literal string sha256=<hex>. Hex (not base64), lowercase, single-line.
  • Clock skewX-EBon-Timestamp is the dispatch attempt timestamp. If you reject deliveries based on a tight skew window (e.g. ±2 minutes), and your server clock has drifted, you will reject otherwise valid requests. The signature itself does not include the timestamp — clock skew only matters if you add a replay-protection check on top.

How to verify

Reproduce locally against a captured payload using openssl:

# RAW_BODY = the exact bytes you received in the POST body, no reformatting.
# SECRET   = your webhook's whsec_… value.

printf '%s' "$RAW_BODY" \
  | openssl dgst -sha256 -hmac "$SECRET" -hex \
  | awk '{print "sha256="$2}'

The result must equal the value of the X-EBon-Signature header byte-for-byte. If it does not, the body you fed openssl differs from the body e-bon hashed — almost always because of re-serialization.

To rule out a stale secret, fetch the current subscription metadata. The secret itself is not returned, but you can confirm enabled, events, url and failureCount:

curl https://api.e-bon.ro/api/v1/org/webhooks/{webhookId} \
  -H "Authorization: Bearer <jwt>"

Then send a controlled test event and compare against the captured request:

curl -X POST https://api.e-bon.ro/api/v1/org/webhooks/{webhookId}/test \
  -H "Authorization: Bearer <jwt>"

Fix

Capture the raw body before any framework parses it

In Express, register a bodyParser.raw({ type: 'application/json' }) route specifically for the webhook path so req.body is a Buffer. In other frameworks, find the equivalent — Fastify's rawBody, NestJS's rawBody: true, FastAPI's await request.body(). Hash that buffer; do not read request.json() first and re-stringify.

Update the stored secret after every rotation

Every time you call POST /api/v1/org/webhooks/{id}/rotate-secret, capture the new secret value from the response and write it to your secret manager (env var, vault, KMS) before the next delivery arrives. The previous secret stops working the instant rotation completes.

Use a constant-time comparison

Compare the two strings with crypto.timingSafeEqual (Node), hmac.compare_digest (Python), subtle.ConstantTimeCompare (Go) — never with ==. This prevents timing side-channels and also catches subtle whitespace-padding bugs that == would silently accept.

If you check timestamp skew, widen the window or fix NTP

If you reject deliveries with |now - X-EBon-Timestamp| > N, ensure your server clock is within N of UTC via chronyc tracking or timedatectl status. The platform recommendation is a window of ±5 minutes, not ±30 seconds — the per-attempt timeout is already 10 s, but retries can land much later.

Canonical signing reference, including the Node and openssl examples used by the platform itself: Webhook events › Verify webhook signatures. Per-event payload shapes and HTTP headers: Webhook events.

Still stuck?

Open a support case at support@e-bon.ro or e-bon.ro/contact with the webhookId, a captured X-EBon-Delivery-Id, the exact X-EBon-Signature header you received, and the digest your endpoint computed (no need to share the secret).