The signature header
The two fields are joined by a single comma, with no spaces, in the order shown. Parse tolerantly (split on
,, then on the first =), but this is the exact form emitted today.
What the signature covers — and what it does not
The signature covers the timestamp and the raw request body, nothing else:- The URL, query string, HTTP method, and all other headers are not signed.
- Integrity and confidentiality of the transport are TLS’s job; the signature proves origin (only a holder of the secret can produce it) and freshness (the timestamp is bound into the signed bytes — a captured payload cannot be re-delivered later under a new timestamp without invalidating
v1).
Verification — the procedure
Perform these steps before parsing or acting on the payload. Reject the delivery (any non-2xx response, e.g.401) if any step fails.
1
Capture the raw request body bytes
Exactly as received — before any framework body-parser, JSON deserialization, or re-encoding touches them.
2
Parse the header
Split
X-Webhook-Signature into t and v1.3
Build the signed string
Concatenate, in order: the value of
t, one ASCII dot . (0x2E), and the raw body bytes:4
Compute the expected signature
HMAC-SHA512 with key = your webhook secret (its UTF-8 bytes, used verbatim — do not hex- or base64-decode it) and message = the signed string. Encode the 64-byte result as lowercase hex.
5
Compare in constant time
Use your platform’s timing-safe comparison (
crypto.timingSafeEqual, hmac.compare_digest, hmac.Equal, MessageDigest.isEqual, hash_equals, CryptographicOperations.FixedTimeEquals, …) against v1. Never use plain string equality.6
Check freshness (strongly recommended)
Reject the delivery if
|now − t| exceeds your tolerance — 5 minutes is a good default. Bloobank does not mandate this check, but it bounds the replay window. It is safe to enforce: every redelivery attempt carries a fresh t and a fresh v1, so rejecting a stale delivery never strands an order — the retry arrives with a valid recent signature.End-to-end worked example
Every value below — including the signature — is real and reproducible. Because the scheme is a symmetric HMAC, you can recomputev1 yourself from the secret, the timestamp, and the body, and use this example as an integration test fixture.
Inputs:
Raw body (exact bytes, no line breaks):
HMAC-SHA512(key = secret, message = signed string), lowercase hex:
- Raw body captured: 826 bytes.
- Header parsed:
t = 1785931205231,v1 = 649e63…67af4. - Signed string built:
1785931205231.+ raw body. - HMAC-SHA512 with the secret → matches
v1. ✅ - Freshness:
|now − 1785931205231|within tolerance. ✅ - Parse JSON, persist, then acknowledge
200text/plainsuccess.
Minimal test vector (for unit tests)
A tiny fixture with no domain payload, convenient for a signature unit test:
If your implementation reproduces this
v1, your canonical construction, key handling, and hex encoding are all correct.
Reference implementations
Illustrative, minimal, and complete. Any mainstream HTTP framework works — the only hard requirement is access to the raw body bytes. Each verifier parsest/v1, enforces the 5-minute freshness window, computes HMAC-SHA512 over {t}.{rawBody}, compares in constant time, and acknowledges with 200 text/plain success only after durable persistence.
Troubleshooting signature mismatches
Work through this list in order — the first three items cover the overwhelming majority of failed verifications.- Re-serialized body. Your framework parsed the JSON and you verified against
JSON.stringify(parsed)instead of the raw bytes. Key order, whitespace, or unicode escapes differ → different bytes → different HMAC. Verify the bytes as received. - Body parser consumed the raw bytes. In Express, a global
express.json()replaces the raw body before your handler runs. Mountexpress.raw()on the webhook routes (or capture the buffer withexpress.json({ verify })). Equivalent pitfalls exist in every framework. - Wrong algorithm. The scheme is HMAC-SHA512 — the signature is 128 hex characters. If you computed 64 hex characters, you used a 256-bit hash; switch to SHA-512.
- Wrong delimiter. The signed string is
{t}.{body}with a single dot. A colon (the IAM request-signing separator), a comma, or nothing at all will not match. - Signed the body only. The timestamp is part of the signed string.
HMAC(body)alone never matches. - Used your own clock for
t. The signed string must embed the exacttfrom the header, not a timestamp you generated. - Decoded the secret. The secret’s UTF-8 bytes are the HMAC key, verbatim. Do not hex-decode or base64-decode it first.
- Wrong output encoding.
v1is hex, not base64. Compare byte-to-byte (decode the hex) or lowercase-hex to lowercase-hex — always with a constant-time comparison. - Uppercase hex. If your HMAC helper emits uppercase hex, normalize before comparing; Bloobank emits lowercase.
- Middleware rewrote the body. An API gateway, WAF, or framework “sanitizer” that re-encodes, pretty-prints, or normalizes the JSON changes the bytes in flight. The signature must be verified against exactly what Bloobank sent — disable body transformations on the webhook path.
If verification passes but deliveries still show as failed on the Bloobank side, the problem is the acknowledgement, not the signature — re-read the
success contract. A JSON-quoted "success", an empty body, or the token ok are the usual suspects.Next
Retry & delivery
What happens when verification or acknowledgement fails.
Webhook payload
The canonical PaymentOrder document.
Best practices
Idempotency, ordering, secret handling.