Signature Verification
Webhook Signature Verification (HMAC)
Every webhook Okoora sends is signed with HMAC-SHA256, using the secret key of the webhook subscription.
Verify the signature on every request before you process it. This proves the request came from Okoora and was not changed on the way.
- Get your secret key
- Get your secret key
- Log in to the Okoora Platform and open Webhook Management.
- Create a subscription: enter your notification URL and choose the events you want.
- A Secret Key is created for each subscription. Click the eye icon to show it, or the copy icon to copy it.
- Store the secret on your server only (for example in a secrets vault or an environment variable). Never put it in client-side code.
Each subscription has its own secret. If you have more than one subscription, verify each request with the secret of the subscription that received it.
- What a webhook request looks like
- What a webhook request looks like
Okoora sends an HTTP POST with a JSON body to your notification URL. The signature is in the X-Hook0-Signature header.
POST /your/webhook/endpoint HTTP/1.1
Content-Type: application/json
X-Hook0-Signature: t=1760774400,h=content-type,v1=5f2b6c0e9a...d41
{
"Id": "3f0b8f5e-6d1a-4c55-9a0e-1f4b2c7d8e90",
"Name": "beneficiary.review.approved",
"AccountId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"Data": { ... },
"CreateAtUtc": "2026-10-18T08:00:00+00:00"
}
| Body field | Description |
|---|---|
Id | Unique ID of the webhook message. It stays the same when the message is retried, so use it to ignore duplicates. |
Name | Event type, for example payment.transfer.paid. |
AccountId | The account the event belongs to. |
Data | Event payload. Its structure depends on the event type. |
CreateAtUtc | When the event was created (UTC). |
- The signature header
- The signature header
X-Hook0-Signature is a list of key=value pairs separated by commas:
| Key | Description |
|---|---|
t | Unix timestamp (in seconds) of when the request was signed. |
h | Names of the HTTP headers included in the signature, separated by spaces. |
v1 | The signature: HMAC-SHA256 as a lowercase hex string. |
v0 | Older signature format, which signs only the timestamp and the body. It may appear next to v1 or instead of it. |
- Verification steps
- Verification steps
- Read the raw request body exactly as you received it. Do not parse and re-serialize the JSON before you verify; any change to the bytes breaks the signature.
- Split
X-Hook0-Signatureinto itst,handv1values. - Build the signed string:
- v1:
{t}.{h}.{header values}.{raw body}. Here{header values}is the value of each header listed inh, in the same order, joined with. - v0 (older format):
{t}.{raw body}
- v1:
- Compute
HMAC-SHA256(secret, signedString)and encode it as lowercase hex. - Compare your result with the received signature using a constant-time comparison.
- Reject the request if
tis more than 5 minutes away from your server's current time. This protects you from replayed requests. - If verification fails, return
401and do not process the event.
const crypto = require('crypto');
const express = require('express');
const app = express();
const WEBHOOK_SECRET = process.env.OKOORA_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
function verifyHook0Signature(rawBody, headers, secret) {
const header = headers['x-hook0-signature'];
if (!header) return false;
const parts = Object.fromEntries(
header.split(',').map(p => {
const i = p.indexOf('=');
return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
})
);
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
let signed, received;
if (parts.v1) {
const names = parts.h ? parts.h.split(' ') : [];
const values = names.map(n => headers[n.toLowerCase()] ?? '').join('.');
signed = `${parts.t}.${parts.h ?? ''}.${values}.${rawBody}`;
received = parts.v1;
} else if (parts.v0) {
signed = `${parts.t}.${rawBody}`;
received = parts.v0;
} else {
return false;
}
const expected = crypto.createHmac('sha256', secret).update(signed, 'utf8').digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(received, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post('/webhooks/okoora', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8');
if (!verifyHook0Signature(rawBody, req.headers, WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(rawBody);
// TODO: skip if event.Id was already processed, otherwise queue for processing
res.sendStatus(200);
});using System.Security.Cryptography;
using System.Text;
public static bool VerifyHook0Signature(string rawBody, IHeaderDictionary headers, string secret)
{
string? header = headers["X-Hook0-Signature"];
if (string.IsNullOrEmpty(header)) return false;
var parts = header.Split(',')
.Select(p => p.Split('=', 2))
.Where(p => p.Length == 2)
.ToDictionary(p => p[0].Trim(), p => p[1].Trim());
if (!parts.TryGetValue("t", out var t) || !long.TryParse(t, out var ts)) return false;
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > 300) return false;
string signed, received;
if (parts.TryGetValue("v1", out var v1))
{
parts.TryGetValue("h", out var h);
var names = string.IsNullOrEmpty(h) ? Array.Empty<string>() : h.Split(' ');
var values = string.Join(".", names.Select(n => headers[n].ToString()));
signed = $"{t}.{h}.{values}.{rawBody}";
received = v1;
}
else if (parts.TryGetValue("v0", out var v0))
{
signed = $"{t}.{rawBody}";
received = v0;
}
else return false;
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expected = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(signed))).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(
Encoding.ASCII.GetBytes(expected),
Encoding.ASCII.GetBytes(received.ToLowerInvariant()));
}Best practices
- Reply fast: return a
2xxstatus within a few seconds, and do the heavy work in the background. Any other response, or a timeout, is treated as a failure and the webhook is retried. - Be idempotent: a webhook can arrive more than once. Use the body
Idto skip events you already handled. - Use HTTPS for your notification URL.
- Rotate the secret if it may have leaked: create a new subscription, move your endpoint to the new secret, then delete the old subscription.
- Every delivery attempt and its response can be seen in Webhook Management, which helps with debugging.
Updated 4 days ago
Did this page help you?