HMAC signature mismatch
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).
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 rotation —
POST /api/v1/org/webhooks/{id}/rotate-secretreturns a newwhsec_…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 skew —
X-EBon-Timestampis 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.
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).
Fix ANAF report rejections (P7B / MF / JE)
Diagnose and resolve an ANAF rejection on a fiscal report — common reasons, how to check status via the reports endpoint, and the fix path for each category.
Tier limit exceeded
Why you see TIER_LIMIT_EXCEEDED, which plan limits trigger it, and how to upgrade.