Skip to content

Verifying signatures

Every webhook BankCore sends is signed with HMAC-SHA256 using a secret shared only between BankCore and your endpoint. Verifying that signature is how you know a callback came from us and that nobody altered the body in transit. Treat an unverified webhook as untrusted input: never post a ledger entry off one.

POST /your/webhook/path HTTP/1.1
Host: your-endpoint.example.com
Content-Type: application/json
X-Webhook-Id: 9f3b2c14-7a6e-4d51-b0c8-2e5a91d47f60
X-Webhook-Event: transaction.completed
X-Webhook-Timestamp: 1788252312
X-Webhook-Signature: sha256=dac0c12ee7ef962b9b30d17e6d80f59e4d72b79b27be9f1968f16e064d75c06e
{"reference":"TXN20260901084512001", ... }
Header Meaning
X-Webhook-Id The delivery’s event id. Stable across retries of the same delivery; see Delivery and retries for how replays differ.
X-Webhook-Event Event type, for example transaction.completed. Also present as type in the body.
X-Webhook-Timestamp Unix time in seconds at the moment this attempt was signed.
X-Webhook-Signature sha256= followed by 64 lowercase hex characters.

The body is compact JSON: no spaces, no trailing newline. Content-Type is application/json with no charset parameter; the bytes are UTF-8.

signed_string = X-Webhook-Timestamp + "." + <raw request body>
signature = HMAC-SHA256(key = webhook_secret, message = signed_string)
header value = "sha256=" + lowercase_hex(signature)

Three details decide whether your implementation works on the first try:

  1. The key is the secret’s characters, not its bytes decoded from hex. The secret looks like hex (64 hex characters) but is used as a UTF-8 string. hmac(secret.encode()), never hmac(bytes.fromhex(secret)). This is the single most common integration failure.
  2. The message is prefixed with the timestamp and a literal ., not the body alone.
  3. The message body is the raw bytes as received, before any JSON parsing. Re-serialising a parsed object changes whitespace and key order, and the digest will never match.

The section on diagnosing a mismatch gives you the exact wrong digest each of these mistakes produces, so you can identify which one you have hit from the value your code computes.

  1. Read the raw body as bytes or a UTF-8 string, before parsing it as JSON.

  2. Parse the timestamp. Reject if the header is missing or not a base-10 integer.

  3. Check freshness. Reject if abs(now - timestamp) exceeds your tolerance. BankCore does not enforce a replay window; this check is entirely yours. 300 seconds is a good default; the section on timestamps below explains why a tight window is safe.

  4. Strip the sha256= prefix from the signature header.

  5. Recompute the HMAC over timestamp + "." + raw_body.

  6. Compare in constant time (hmac.Equal, hash_equals, MessageDigest.isEqual, CryptographicOperations.FixedTimeEquals). A plain == on the digest string leaks timing. Lowercase the received value first, or compare case-insensitively.

  7. Only then parse the JSON and act on it.

Respond 2xx to acknowledge. Anything else is a failure, and a 4xx is never retried; read Delivery and retries before you choose a status code for a verification failure.

Use these to unit-test your verifier offline. The values below were produced by the BankCore signing code and independently reproduced in Java, C#, PHP and Python.

3f8a1c9e7b2d4056a8e1f37c9b0d5e246a7c8f19b3d502e4c6a81f7b9d0e3c52

64 characters. Used as 64 ASCII bytes, not 32 decoded bytes. This is a documentation value; your real secret is handed to you by the bank.

Deliberately tiny so you can eyeball every byte.

Timestamp (1788249600 = 2026-09-01T08:00:00Z):

1788249600

Raw body (72 bytes):

{"type":"webhook.test","eventId":"11111111-2222-3333-4444-555555555555"}

Signed string (83 bytes: timestamp, then a literal ., then the body verbatim):

1788249600.{"type":"webhook.test","eventId":"11111111-2222-3333-4444-555555555555"}

Expected signature:

8799cfa54e929b803a95546a6f109dc72c64d98c54ffc8c514d5354ca3b1df74

Header as sent:

X-Webhook-Signature: sha256=8799cfa54e929b803a95546a6f109dc72c64d98c54ffc8c514d5354ca3b1df74

You can reproduce it on any machine with a shell:

Terminal window
printf '%s' '1788249600.{"type":"webhook.test","eventId":"11111111-2222-3333-4444-555555555555"}' | openssl dgst -sha256 -hmac '3f8a1c9e7b2d4056a8e1f37c9b0d5e246a7c8f19b3d502e4c6a81f7b9d0e3c52'

Vector B: a real transaction.completed payload

Section titled “Vector B: a real transaction.completed payload”

Same secret. Timestamp 1788252312 (2026-09-01T08:45:12Z). Body is 695 bytes, signed string 706 bytes. The body is one line; it is wrapped here only for reading. Join it with no whitespace before signing.

{"reference":"TXN20260901084512001","clientReference":"CHN-8842190","txType":"NIP_TRANSFER_OUT","amount":50000.00,"currency":"NGN","sourceAccountNumber":"6021234567","destinationAccountNumber":"0123456789","customerType":"INDIVIDUAL","sourceChannel":"MOBILE","accountNumber":"6021234567","accountName":"ADEOLA MARTINS","customerName":"ADEOLA MARTINS","customerPhone":"08031234567","beneficiaryName":"CHINEDU OKAFOR","beneficiaryAccount":"0123456789","beneficiaryBank":"000013","beneficiaryBankName":"GTBank","nipSessionId":"000013260901084512123456789012","feeAmount":10.75,"vatAmount":0.81,"stampDutyAmount":50.00,"type":"transaction.completed","eventId":"9f3b2c14-7a6e-4d51-b0c8-2e5a91d47f60"}

Expected signature:

dac0c12ee7ef962b9b30d17e6d80f59e4d72b79b27be9f1968f16e064d75c06e

Note "amount":50000.00: trailing zeros are significant bytes. Anything that normalises that to 50000.0 or 50000 has re-serialised the body and will fail. Hold the raw bytes. (The txType value in this vector is a documentation value; live payloads use the names in Field dictionaries.)

End-to-end check against your live endpoint

Section titled “End-to-end check against your live endpoint”

Vector A is deliberately stale, so posting it is a negative test: a correct endpoint rejects it on the freshness check even though the signature is valid.

Terminal window
curl -i -X POST https://your-endpoint.example.com/webhooks/bankcore \
-H 'Content-Type: application/json' \
-H 'X-Webhook-Id: 11111111-2222-3333-4444-555555555555' \
-H 'X-Webhook-Event: webhook.test' \
-H 'X-Webhook-Timestamp: 1788249600' \
-H 'X-Webhook-Signature: sha256=8799cfa54e929b803a95546a6f109dc72c64d98c54ffc8c514d5354ca3b1df74' \
-d '{"type":"webhook.test","eventId":"11111111-2222-3333-4444-555555555555"}'

For the positive test, sign a fresh timestamp:

Terminal window
SECRET='3f8a1c9e7b2d4056a8e1f37c9b0d5e246a7c8f19b3d502e4c6a81f7b9d0e3c52'
BODY='{"type":"webhook.test","eventId":"11111111-2222-3333-4444-555555555555"}'
TS=$(date +%s)
SIG=$(printf '%s' "$TS.$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $NF}')
curl -i -X POST https://your-endpoint.example.com/webhooks/bankcore \
-H 'Content-Type: application/json' \
-H 'X-Webhook-Id: 11111111-2222-3333-4444-555555555555' \
-H 'X-Webhook-Event: webhook.test' \
-H "X-Webhook-Timestamp: $TS" \
-H "X-Webhook-Signature: sha256=$SIG" \
-d "$BODY"

Once your endpoint is registered, ask your relationship manager to fire a test event. It goes through the production signing path with X-Webhook-Event: webhook.test, an X-Webhook-Id of the form test-<uuid>, and a body of the shape below. Note that the real test body carries event, not type, and no eventId key; the vectors above use a simplified body on purpose.

{"event":"webhook.test","subscription_id":"c0ffee00-1111-2222-3333-444444444444","timestamp":"2026-09-01T08:00:00Z","message":"This is a test webhook delivery."}

All three use Vector A’s secret, timestamp and body. If your code produces one of these instead of 8799cf…, you have found your bug.

Your digest starts with Cause Fix
9ff96d64… Secret was hex-decoded into 32 bytes before use Use the secret’s 64 characters as UTF-8 bytes
4feb7435… Signed the body only, no timestamp prefix Sign timestamp + "." + body
d0c67e08… Body was parsed and re-serialised (pretty-printed) Capture the raw body before JSON parsing

Full values:

correct 8799cfa54e929b803a95546a6f109dc72c64d98c54ffc8c514d5354ca3b1df74
hex-decoded key 9ff96d6439c0b04f339440b8f2de329522eeb61e8910c78eeef26b1b7d35c5e8
no timestamp prefix 4feb7435e7e420433758de8d2efca4f4ecc00843af783a982dda6abff111743c
re-serialized body d0c67e08490c0b52c0207ac8dde92c0ea4e5b49ab73add7e70beb8bad8201b6b

The third case is worth spelling out, because most frameworks do it to you silently. The digest d0c67e08… is what you get from signing this:

{
"type": "webhook.test",
"eventId": "11111111-2222-3333-4444-555555555555"
}

Semantically identical JSON. Completely different bytes. Completely different digest.

If your digest matches none of the above, check in this order: the secret is correct and has no stray whitespace or newline (a trailing \n from a file read is common); you stripped sha256=; you are comparing lowercase; your body reader is not silently transcoding or truncating.

Each of these reads the raw body, enforces a 5-minute replay window, and compares in constant time. The Java, C# and PHP versions were executed against Vector A and reproduce 8799cfa5….

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.HexFormat;
public final class BankCoreWebhookVerifier {
private static final long TOLERANCE_SECONDS = 300;
private final String secret;
public BankCoreWebhookVerifier(String secret) {
this.secret = secret;
}
public boolean isValid(String timestampHeader, String signatureHeader, byte[] rawBody) {
if (timestampHeader == null || signatureHeader == null) return false;
long timestamp;
try {
timestamp = Long.parseLong(timestampHeader.trim());
} catch (NumberFormatException e) {
return false;
}
if (Math.abs(Instant.now().getEpochSecond() - timestamp) > TOLERANCE_SECONDS) return false;
String received = signatureHeader.startsWith("sha256=")
? signatureHeader.substring(7)
: signatureHeader;
String expected = hmacSha256Hex(
timestamp + "." + new String(rawBody, StandardCharsets.UTF_8));
return MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
received.toLowerCase().getBytes(StandardCharsets.UTF_8));
}
private String hmacSha256Hex(String data) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return HexFormat.of().formatHex(mac.doFinal(data.getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) {
throw new IllegalStateException("HMAC computation failed", e);
}
}
}

Spring MVC controller. Bind byte[], not a DTO. Spring would otherwise parse and hand you an object you cannot re-derive the original bytes from:

@PostMapping(path = "/webhooks/bankcore", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Void> receive(
@RequestHeader("X-Webhook-Id") String eventId,
@RequestHeader("X-Webhook-Timestamp") String timestamp,
@RequestHeader("X-Webhook-Signature") String signature,
@RequestBody byte[] rawBody) throws IOException {
if (!verifier.isValid(timestamp, signature, rawBody)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
if (!processedEvents.claim(eventId)) {
return ResponseEntity.ok().build(); // already handled — ack and move on
}
WebhookPayload payload = objectMapper.readValue(rawBody, WebhookPayload.class);
queue.submit(payload); // hand off; do not process inline
return ResponseEntity.ok().build();
}

The timestamp does two separate jobs, and conflating them is what causes trouble.

Inside the HMAC it is just bytes. The signed message is timestamp + "." + body, so the timestamp is covered by the signature and cannot be tampered with. An attacker cannot rewrite it to refresh a captured request; the digest would stop matching.

Outside the HMAC it is your freshness reference. This is load-bearing, because a valid (timestamp, body, signature) triple stays cryptographically valid forever. Nothing in the signature expires on its own. The only thing standing between you and someone replaying a captured request is your own tolerance check.

The timestamp is generated at the moment the HTTP request is built, once per attempt, not when the underlying event occurred. A webhook for an event from five days ago arrives with a timestamp of now, and every retry is re-signed with a fresh timestamp and a fresh signature while X-Webhook-Id stays the same. Do not treat a repeated signature as your dedupe key.

Kind of delay Counts against your window? Why
Waiting in our delivery queue No Timestamp is stamped at send, not at event time
Retry backoff, up to about five days No Every attempt is signed fresh
Subscription disabled, later re-enabled No Same: signed when it finally goes out
Replay of historical events No Same
Network transit and TLS handshake Yes Elapses after we stamp it
Queuing on your side before verification runs Yes Your own backlog eats the window
Clock skew between our host and yours Yes The most common cause of unexplained failures

So a delayed webhook does not fail verification because it was delayed. It fails because of transit time, your own internal queuing, or disagreeing clocks.

  • Verify at the edge, in middleware or the handler, before the request enters any queue of your own. Verifying inside a background job turns your own backlog into signature failures.
  • Check both directions. Use abs(now - timestamp) > tolerance, never now - timestamp > tolerance. If our clock runs ahead of yours the difference goes negative, and a one-sided check accepts arbitrarily future-dated requests forever.
  • Run NTP on the receiving host. Skew is invisible in your logs: the digest is correct the whole time and only the freshness check fails.
  • If you are unsure whether your clock is trustworthy, log the observed skew (now - timestamp) on every request for a week before tightening the window.

The portal never shows a secret. Webhook endpoints lists your subscriptions, and a delivery’s page shows the message that was sent and what your server answered, which is where a 401 from your side becomes visible.