Webhook - Bank
This section explains how to receive callbacks when the verification status of a user's linked bank account changes. The bank webhook is common to On-Ramp and Off-Ramp — the same callback and payload apply to both.
Configuring Webhook
Webhooks are configured in the merchant dashboard.
Inside the Setup section, the bank webhook URL can be added and modified under the Callback URL's section.
The bank webhook has its own callback URL. It is separate from the On-Ramp and Off-Ramp order webhook URLs, so make sure a URL is configured for it. If no bank webhook URL is configured for your account, no bank webhook is sent.
Webhook Security & HMAC Verification
Your webhook endpoint is a public URL — anyone on the internet can send a POST request to it. Without verification, a malicious actor could forge a fake "account successfully added" event and trick your system into treating an unverified bank account as verified.
How it works
Every bank webhook carries a signature in the X-Onmeta-Signature request header. It is an HMAC-SHA256 of the exact request body, generated with your API Secret (the Client Secret from the merchant dashboard) and hex-encoded.
HMAC-SHA256(apiSecret, rawRequestBody) === X-Onmeta-Signature
Compute the signature over the raw request body exactly as received, before parsing it. Re-serialising a parsed JSON object can change spacing or escaping and produce a different hash.
Verification Example
- Node.js
- Python
- PHP
const crypto = require('crypto');
const express = require('express');
const app = express();
// Keep the body raw so the HMAC is computed over the exact bytes received
app.post('/onmeta/bank-webhook', express.raw({ type: 'application/json' }), (req, res) => {
const receivedSignature = req.headers['x-onmeta-signature'];
const apiSecret = process.env.ONMETA_API_SECRET; // from merchant dashboard
const expectedSignature = crypto
.createHmac('sha256', apiSecret)
.update(req.body) // Buffer containing the raw body
.digest('hex');
const valid =
receivedSignature &&
receivedSignature.length === expectedSignature.length &&
crypto.timingSafeEqual(Buffer.from(expectedSignature), Buffer.from(receivedSignature));
if (!valid) {
return res.status(401).send('Invalid webhook signature');
}
const event = JSON.parse(req.body.toString('utf8'));
// handle event ...
res.sendStatus(200);
});
import hmac
import hashlib
import os
def verify_webhook(raw_body: bytes, received_signature: str) -> bool:
api_secret = os.environ['ONMETA_API_SECRET'] # from merchant dashboard
expected = hmac.new(
api_secret.encode('utf-8'),
raw_body,
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, received_signature):
raise ValueError('Invalid webhook signature — request may be forged')
return True
function verifyWebhook(string $rawBody, string $receivedSignature): bool {
$apiSecret = getenv('ONMETA_API_SECRET'); // from merchant dashboard
$expected = hash_hmac('sha256', $rawBody, $apiSecret);
if (!hash_equals($expected, $receivedSignature)) {
throw new Exception('Invalid webhook signature — request may be forged');
}
return true;
}
// $rawBody = file_get_contents('php://input');
Log in to your Merchant Dashboard → Setup → API's Setup. Use the Client Secret (not the Client ID) as the HMAC secret. Keep this value private and never expose it in client-side code or public repositories.
Bank Verification Update
{{configured_bank_webhook_url}}This callback is triggered each time the verification result of a bank account linked by your user is recorded — including when the account is verified successfully. It uses the configured bank webhook URL and sends the details in the POST body.
Make sure you have firewall rules configured to allow receiving the webhook body, otherwise your firewall might block our webhook requests.
Your endpoint should respond with a 2xx status within 5 seconds.
Headers
| Header | Type | Required | Description |
|---|---|---|---|
Content-Type | string | Yes | application/json; charset=utf-8 |
X-Onmeta-Signature | string | Yes | HMAC-SHA256 signature of the raw request body, for webhook verification |
Request Body Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Name of the user as per KYC |
email | string | Yes | Email of the user who linked the bank account |
bankDetails | object | Yes | Details of the bank account the event is about |
bankDetails.status | string | Yes | Verification status of the bank account: SUCCESS, PENDING, FAILED or MANUAL |
bankDetails.referenceNumber | string | Yes | Reference number of the bank account. Same value returned by the Link Bank Account API — use it to match the event to your request |
bankDetails.accountNumber | string | No | Bank account number |
bankDetails.accountName | string | No | Account holder name |
bankDetails.ifsc | string | No | IFSC code of the bank branch |
bankDetails.branchAddress | string | No | Branch address |
bankDetails.tenantId | string | No | Your merchant (tenant) ID |
message | string | Yes | Human-readable description of the event. See Webhook Events |
reason | string | Yes | Reason for the status. See Webhook Events for the value sent with each status |
Fields marked No are left out of bankDetails when they have no value. reason is always present.
Code Examples
- cURL
- Node.js
curl --location -g --request POST '{{configured_bank_webhook_url}}' \
--header 'Content-Type: application/json; charset=utf-8' \
--header 'X-Onmeta-Signature: string' \
--data-raw '{
"name": "Ravi Kumar",
"email": "ravi@example.com",
"bankDetails": {
"accountNumber": "123456789012",
"accountName": "RAVI KUMAR",
"ifsc": "HDFC0001234",
"branchAddress": "MG Road, Bengaluru",
"referenceNumber": "a1b2c3d4-1234-5678-abcd-ef1234567890",
"tenantId": "65ef27a598b805cba22fb3b5",
"status": "SUCCESS"
},
"message": "account successfully added",
"reason": "account successfully added"
}'
const crypto = require('crypto');
// rawBody: the request body exactly as received (string or Buffer)
function verifyWebhookSignature(rawBody, signature, apiSecret) {
const hash = crypto.createHmac('sha256', apiSecret).update(rawBody).digest('hex');
return hash === signature;
}
Example Webhook Request
{
"name": "Ravi Kumar",
"email": "ravi@example.com",
"bankDetails": {
"accountNumber": "123456789012",
"accountName": "RAVI KUMAR",
"ifsc": "HDFC0001234",
"branchAddress": "MG Road, Bengaluru",
"referenceNumber": "a1b2c3d4-1234-5678-abcd-ef1234567890",
"tenantId": "65ef27a598b805cba22fb3b5",
"status": "SUCCESS"
},
"message": "account successfully added",
"reason": "account successfully added"
}
Webhook Events
The bank webhook has a single payload shape. The event is identified by bankDetails.status and the matching message:
| # | Status | Message | Description |
|---|---|---|---|
| 1 | SUCCESS | account successfully added | The bank account was verified and added to the user's account. The user can now use it for orders. |
| 2 | PENDING | account verification pending | Verification is still in progress and the account cannot be used yet. You can check the latest status using the Bank Verification Status API. |
| 3 | FAILED | account/pan name mismatch. ACCOUNT HOLDER NAME: <name> | Verification failed because the account holder name does not match the user's KYC name. The message ends with the account holder name returned by the bank. The user has to link a bank account in their own name. |
| 4 | MANUAL | pending for manual verification | The account holder name could not be matched automatically with the user's KYC name, so the account has been sent to Onmeta for manual review. The account cannot be used until the review is complete. |
See Sample Responses for a full payload for each status.
Handling the Webhook
- Match on
referenceNumber. It is the same reference number returned when the bank account was linked, so use it to find the user's request on your side. - Branch on
bankDetails.status. Treatmessageas display text, not as a value to parse. ForFAILED, the message includes the account holder name, so it is different on every event. - Expect more than one webhook per bank account. The status of an account can change over time, for example from
PENDINGorMANUALtoSUCCESSorFAILED; always apply the latest event. - Make your handler idempotent. The same status can be delivered more than once for a bank account, for example when a verification result is reported again. Key on
referenceNumberandbankDetails.statusand ignore events you have already processed. - Respond quickly. Return a
2xxstatus within 5 seconds and do any heavy processing asynchronously.