Skip to main content
Every webhook delivery is signed. Verify the signature before parsing or acting on the payload — an unverified webhook is an injection vector for fake payment confirmations. The scheme is Webhook Signature v1: HMAC-SHA512 with your per-merchant shared secret. The secret is symmetric — Bloobank uses it to sign, you use it to verify. Treat it with the same care as a private key: anyone holding it can forge webhooks that pass your validation.

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.
Only after all checks pass should you parse the JSON body.
The three most common mistakes:
  1. Verify the raw bytes — never a re-serialized version. If your framework parses the JSON and you re-stringify it to verify, key order, whitespace, escape sequences, or unicode normalization can differ by a single byte and the HMAC will not match. Capture the bytes as they arrived on the socket.
  2. The delimiter is a dot . — not a colon. If you also integrate with the Bloobank IAM request-signing protocol (colon-separated canonical string), do not reuse that separator here. The webhook signed string has exactly one delimiter: {t}.{body}.
  3. Use t from the header — not your own clock. The timestamp inside the signed string must be the exact t value Bloobank sent. Reading your own clock produces a different string and a guaranteed mismatch.

End-to-end worked example

Every value below — including the signature — is real and reproducible. Because the scheme is a symmetric HMAC, you can recompute v1 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):
Signed string — the timestamp, a dot, then the raw body:
Expected signatureHMAC-SHA512(key = secret, message = signed string), lowercase hex:
The HTTP request you receive:
Your validation, step by step:
  1. Raw body captured: 826 bytes.
  2. Header parsed: t = 1785931205231, v1 = 649e63…67af4.
  3. Signed string built: 1785931205231. + raw body.
  4. HMAC-SHA512 with the secret → matches v1. ✅
  5. Freshness: |now − 1785931205231| within tolerance. ✅
  6. Parse JSON, persist, then acknowledge 200 text/plain success.

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 parses t/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.
All six implementations reproduce the test vector above. Wire it into your unit tests before pointing Bloobank at the endpoint.

Troubleshooting signature mismatches

Work through this list in order — the first three items cover the overwhelming majority of failed verifications.
  1. 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.
  2. Body parser consumed the raw bytes. In Express, a global express.json() replaces the raw body before your handler runs. Mount express.raw() on the webhook routes (or capture the buffer with express.json({ verify })). Equivalent pitfalls exist in every framework.
  3. 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.
  4. 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.
  5. Signed the body only. The timestamp is part of the signed string. HMAC(body) alone never matches.
  6. Used your own clock for t. The signed string must embed the exact t from the header, not a timestamp you generated.
  7. Decoded the secret. The secret’s UTF-8 bytes are the HMAC key, verbatim. Do not hex-decode or base64-decode it first.
  8. Wrong output encoding. v1 is hex, not base64. Compare byte-to-byte (decode the hex) or lowercase-hex to lowercase-hex — always with a constant-time comparison.
  9. Uppercase hex. If your HMAC helper emits uppercase hex, normalize before comparing; Bloobank emits lowercase.
  10. 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.