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.
What arrives at your endpoint
Section titled “What arrives at your endpoint”POST /your/webhook/path HTTP/1.1Host: your-endpoint.example.comContent-Type: application/jsonX-Webhook-Id: 9f3b2c14-7a6e-4d51-b0c8-2e5a91d47f60X-Webhook-Event: transaction.completedX-Webhook-Timestamp: 1788252312X-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.
The algorithm
Section titled “The algorithm”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:
- 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()), neverhmac(bytes.fromhex(secret)). This is the single most common integration failure. - The message is prefixed with the timestamp and a literal
., not the body alone. - 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.
Verification procedure
Section titled “Verification procedure”-
Read the raw body as bytes or a UTF-8 string, before parsing it as JSON.
-
Parse the timestamp. Reject if the header is missing or not a base-10 integer.
-
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. -
Strip the
sha256=prefix from the signature header. -
Recompute the HMAC over
timestamp + "." + raw_body. -
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. -
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.
Worked example
Section titled “Worked example”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.
Shared test secret
Section titled “Shared test secret”3f8a1c9e7b2d4056a8e1f37c9b0d5e246a7c8f19b3d502e4c6a81f7b9d0e3c5264 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.
Vector A: minimal
Section titled “Vector A: minimal”Deliberately tiny so you can eyeball every byte.
Timestamp (1788249600 = 2026-09-01T08:00:00Z):
1788249600Raw 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:
8799cfa54e929b803a95546a6f109dc72c64d98c54ffc8c514d5354ca3b1df74Header as sent:
X-Webhook-Signature: sha256=8799cfa54e929b803a95546a6f109dc72c64d98c54ffc8c514d5354ca3b1df74You can reproduce it on any machine with a shell:
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:
dac0c12ee7ef962b9b30d17e6d80f59e4d72b79b27be9f1968f16e064d75c06eNote "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.
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:
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."}Diagnosing a mismatch
Section titled “Diagnosing a mismatch”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 8799cfa54e929b803a95546a6f109dc72c64d98c54ffc8c514d5354ca3b1df74hex-decoded key 9ff96d6439c0b04f339440b8f2de329522eeb61e8910c78eeef26b1b7d35c5e8no timestamp prefix 4feb7435e7e420433758de8d2efca4f4ecc00843af783a982dda6abff111743cre-serialized body d0c67e08490c0b52c0207ac8dde92c0ea4e5b49ab73add7e70beb8bad8201b6bThe 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.
Implementations
Section titled “Implementations”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();}package webhooks
import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "io" "net/http" "strconv" "strings" "time")
const toleranceSeconds = 300
// Verify reads and validates the request. It returns the raw body so the caller// can decode it after — and only after — the signature checks out.func Verify(r *http.Request, secret string) (body []byte, ok bool) { body, err := io.ReadAll(r.Body) if err != nil { return nil, false }
tsHeader := r.Header.Get("X-Webhook-Timestamp") sigHeader := r.Header.Get("X-Webhook-Signature") if tsHeader == "" || sigHeader == "" { return body, false }
ts, err := strconv.ParseInt(strings.TrimSpace(tsHeader), 10, 64) if err != nil { return body, false } if skew := time.Now().Unix() - ts; skew > toleranceSeconds || skew < -toleranceSeconds { return body, false }
received := strings.TrimPrefix(sigHeader, "sha256=")
mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(strconv.FormatInt(ts, 10) + "." + string(body))) expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(strings.ToLower(received))) { return body, false } return body, true}Handler. Note the body is read by Verify; do not call json.NewDecoder(r.Body) first, or the stream is already drained:
func Handler(secret string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { body, ok := Verify(r, secret) if !ok { http.Error(w, "invalid signature", http.StatusUnauthorized) return }
eventID := r.Header.Get("X-Webhook-Id") if seen, _ := store.Claim(r.Context(), eventID); seen { w.WriteHeader(http.StatusOK) return }
var payload WebhookPayload if err := json.Unmarshal(body, &payload); err != nil { http.Error(w, "malformed payload", http.StatusBadRequest) return }
go process(payload) w.WriteHeader(http.StatusOK) }}using System.Security.Cryptography;using System.Text;
public sealed class BankCoreWebhookVerifier{ private const int ToleranceSeconds = 300;
private readonly string _secret;
public BankCoreWebhookVerifier(string secret) => _secret = secret;
public bool IsValid(string? timestampHeader, string? signatureHeader, byte[] rawBody) { if (timestampHeader is null || signatureHeader is null) return false; if (!long.TryParse(timestampHeader.Trim(), out var timestamp)) return false;
var skew = Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - timestamp); if (skew > ToleranceSeconds) return false;
var received = signatureHeader.StartsWith("sha256=", StringComparison.Ordinal) ? signatureHeader[7..] : signatureHeader;
var expected = HmacSha256Hex($"{timestamp}.{Encoding.UTF8.GetString(rawBody)}");
return CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(received.ToLowerInvariant())); }
private string HmacSha256Hex(string data) { using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(_secret)); return Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(data))) .ToLowerInvariant(); }}ASP.NET Core minimal API. Read the body off the stream yourself; model binding would consume and re-shape it:
app.MapPost("/webhooks/bankcore", async (HttpRequest request, BankCoreWebhookVerifier verifier, IEventStore store) =>{ using var buffer = new MemoryStream(); await request.Body.CopyToAsync(buffer); var rawBody = buffer.ToArray();
var timestamp = request.Headers["X-Webhook-Timestamp"].FirstOrDefault(); var signature = request.Headers["X-Webhook-Signature"].FirstOrDefault();
if (!verifier.IsValid(timestamp, signature, rawBody)) return Results.Unauthorized();
var eventId = request.Headers["X-Webhook-Id"].FirstOrDefault(); if (await store.AlreadyHandledAsync(eventId)) return Results.Ok();
var payload = JsonSerializer.Deserialize<WebhookPayload>(rawBody); await store.EnqueueAsync(eventId, payload); return Results.Ok();});Middleware, so verification happens before any controller or model binding runs.
<?php
namespace App\Http\Middleware;
use Closure;use Illuminate\Http\Request;use Symfony\Component\HttpFoundation\Response;
class VerifyBankCoreSignature{ private const TOLERANCE_SECONDS = 300;
public function handle(Request $request, Closure $next): Response { $secret = config('services.bankcore.webhook_secret');
$timestampHeader = $request->header('X-Webhook-Timestamp'); $signatureHeader = $request->header('X-Webhook-Signature');
if ($timestampHeader === null || $signatureHeader === null) { abort(401, 'Missing signature headers'); }
$timestampHeader = trim($timestampHeader); if (! ctype_digit($timestampHeader)) { abort(401, 'Malformed timestamp'); }
$timestamp = (int) $timestampHeader; if (abs(time() - $timestamp) > self::TOLERANCE_SECONDS) { abort(401, 'Timestamp outside tolerance'); }
$received = str_starts_with($signatureHeader, 'sha256=') ? substr($signatureHeader, 7) : $signatureHeader;
// getContent() is the raw body. Never rebuild it from $request->all() // or json_encode($request->json()->all()) — that re-serializes. $expected = hash_hmac('sha256', $timestamp.'.'.$request->getContent(), $secret);
if (! hash_equals($expected, strtolower($received))) { abort(401, 'Invalid signature'); }
return $next($request); }}Controller:
public function handle(Request $request){ $eventId = $request->header('X-Webhook-Id');
// Unique index on webhook_events.event_id makes this the idempotency gate. $event = WebhookEvent::firstOrCreate( ['event_id' => $eventId], ['event_type' => $request->header('X-Webhook-Event'), 'payload' => $request->json()->all()] );
if ($event->wasRecentlyCreated) { ProcessBankCoreWebhook::dispatch($event); }
return response()->noContent();}Register the route and exempt it from CSRF; a webhook carries no session token.
Route::post('/webhooks/bankcore', [BankCoreWebhookController::class, 'handle']) ->middleware(VerifyBankCoreSignature::class);On Laravel 11+, routes/api.php is CSRF-exempt already. On Laravel 10 and earlier, if the route lives in routes/web.php, add its path to $except in App\Http\Middleware\VerifyCsrfToken.
Keep the secret in config/services.php reading from the environment, never in the repository:
'bankcore' => [ 'webhook_secret' => env('BANKCORE_WEBHOOK_SECRET'),],Timestamps, delay and your replay window
Section titled “Timestamps, delay and your replay window”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.
When the clock is read
Section titled “When the clock is read”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.
What this means for your tolerance
Section titled “What this means for your tolerance”- 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, nevernow - 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.
In the portal
Section titled “In the portal”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.