Verifia

DevelopersAnyBanQ Pay

AnyBanQ Pay developer guide

Verify a customer, collect from their Sri Lankan bank account, take a remittance from abroad and pay many accounts at once. One library on your server, one tag in your page, and signed webhooks back to you.

Read this first

What is live today

Not everything on this page moves real money yet. Build against all of it; plan a launch only around the rows marked live.

  • Identity verification

    Live

    Hosted sessions run the workflow you published. Which checks reach a real provider depends on the checks in it.

  • Collection from a local bank account

    Live

    One rail: HNB JustPay. The customer links an HNB account on the hosted pay page and approves the debit there.

  • Card collection

    Not live

    No card acquirer is connected. A customer cannot pay a bill, a link or a remittance order with a real card.

  • Remittance

    Test mode

    Orders are collected on a test card page and no money moves. Each one is marked sandbox: true.

  • Payouts to bank accounts

    Not live

    A remittance payout and every row of a payout batch are recorded as DRY_RUN. Nothing is sent to a bank.

Remittance and bulk payouts are also switched on per organisation. Until they are on for yours, their endpoints answer 403. See which services each area needs.

How it fits

A key on your server, a token in the page, a webhook back

  1. 1

    Your server creates the thing

    A verification session, a payment link, a remittance order or a payout batch, using your API key. The key never leaves your server.

  2. 2

    Your server hands the page a token

    A session token or a checkout token. It names one verification or one order, shows only what the customer needs to see, and expires.

  3. 3

    The page opens our hosted flow

    anybanq.js opens it in a frame served from our origin. Identity documents, bank details and card numbers are entered there, not on your page.

  4. 4

    We tell your server what happened

    A webhook, signed with your webhook secret. Treat a browser callback as a prompt to refresh; treat the webhook, or a read with your key, as the fact.

How the server key, the browser token and webhooks fit togetherYour server calls AnyBanQ with the API key and gets a token. Your server gives only the token to the customer's browser. The browser opens the AnyBanQ hosted page with the token. AnyBanQ sends a signed webhook to your server with the result.Your serverholds the API key and the webhook secretAnyBanQthe API and the hosted pagesCustomer's browseryour page, with anybanq.js1create,with API key4webhook,signed3hosted page,opened with the token2token only, never the key

Before you start

A key, your domains, the library

1. Get an API key

Sign in to the console and open API keys. Create a key and copy it then: it is shown once and we keep only a hash. A key that starts divp_test_ works while your account is still being reviewed, and what it makes is a test record. A key that starts divp_live_ needs an approved account.

Send it as a bearer token on every request to https://verifia.anybanq.lk/v1. You can narrow a key to scopes when you create it; the table below lists the scope each area needs. A scoped key reaches only what its scopes cover; an unscoped key can do everything a key is allowed to. No key can manage keys, webhooks, your team or your settings.

A request with a key
curl https://verifia.anybanq.lk/v1/remittance/orders \
  -H "Authorization: Bearer $ANYBANQ_API_KEY"

2. List the domains that may embed us

The verification flow and the remittance checkout open inside a frame on your page. We refuse to be framed by a site you have not named, so add your site first: in the console open Workflows, open a workflow, and fill in the allowed domains under Share & Embed. Enter bare hosts such as app.example.lk, or *.example.lk. Only https pages are allowed. The list is one per organisation and covers both flows.

The pay button and payment links need no entry: they send the customer to our pay page in a tab of its own, which is never framed.

3. Download the server library

The library is a file you download and keep in your project. It is not on npm and there is nothing to install. The files are in Downloads, just below.

4. Add the browser tag

One tag
<script src="https://verifia.anybanq.lk/sdk/v1/anybanq.js"></script>
Gives you AnyBanQ.verify.open, AnyBanQ.remit.open and pay buttons. It loads verifia.js, remit.js and pay-button.js from the same place; if your page already uses those, they keep working unchanged.

Downloads

One file for Node, a collection for everything else

On a Node server, take the library: one JavaScript file, Node 18 or newer, no dependencies and no install step. On any other language, take the API collection and call the endpoints directly; every call on this page is also shown as raw HTTP.

  • Server library, ES modules

    anybanq-pay.mjs

    For a project that uses import. One file, no dependencies.

    Download anybanq-pay.mjs
  • Server library, CommonJS

    anybanq-pay.cjs

    The same library for a project that uses require.

    Download anybanq-pay.cjs
  • TypeScript types

    anybanq-pay.d.ts

    Optional. Save it beside the library as anybanq-pay.d.mts (with the .mjs file) or anybanq-pay.d.cts (with the .cjs file): TypeScript looks for a declaration named after the module.

    Download anybanq-pay.d.ts
  • API collection, OpenAPI 3.1

    anybanq-pay.openapi.json

    For any other language. Only the endpoints on this page and the webhook payloads, with an example for every request and response.

    Download anybanq-pay.openapi.json
  • Postman collection

    anybanq-pay.postman_collection.json

    The same requests, with variables for the host and your key, and signed example webhooks you can send to your own endpoint.

    Download anybanq-pay.postman_collection.json
Or from a terminal
curl -O https://verifia.anybanq.lk/sdk/v1/server/anybanq-pay.mjs
curl -O https://verifia.anybanq.lk/sdk/v1/server/anybanq-pay.cjs
curl -O https://verifia.anybanq.lk/sdk/v1/server/anybanq-pay.d.ts
curl -O https://verifia.anybanq.lk/sdk/v1/server/anybanq-pay.openapi.json
curl -O https://verifia.anybanq.lk/sdk/v1/server/anybanq-pay.postman_collection.json

Use it

Put the file in your project and import it by its path. The first lines of each file carry its version and build date.

ES modules
import { AnybanqPay } from "./anybanq-pay.mjs";
const pay = new AnybanqPay({ apiKey: process.env.ANYBANQ_API_KEY });
CommonJS
const { AnybanqPay } = require("./anybanq-pay.cjs");
const pay = new AnybanqPay({ apiKey: process.env.ANYBANQ_API_KEY });
Server only: the constructor throws if it finds a browser, so the key cannot reach a page by accident. A key starts divp_test_ or divp_live_. The library makes one request per call and never retries by itself.

The samples below are written for the ES module file. With CommonJS, replace each import { … } from "./anybanq-pay.mjs" with const { … } = require("./anybanq-pay.cjs"); everything else is the same.

To update, download the file again and replace your copy. Check the version in its header against the one you have.

1 · Identity

Verify a customer

Build and publish a workflow in the console: it decides which checks run and in what order. Then start one session per customer. The session's token is what the browser gets.

Server · library
const session = await pay.verifications.createSession(WORKFLOW_ID, {
  subjectRef: "customer-42", // your own reference, up to 120 characters
  ttlHours: 2,               // default 24
});

// Give the page the token and nothing else.
res.json({ token: session.token });

// No embed? Send the customer to session.hostedUrl instead.
Server · raw HTTP
curl -X POST https://verifia.anybanq.lk/v1/workflows/$WORKFLOW_ID/sessions \
  -H "Authorization: Bearer $ANYBANQ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "subjectRef": "customer-42", "ttlHours": 2 }'

# 201
# { "id": "…", "token": "vs_…", "status": "PENDING", "subjectRef": "customer-42",
#   "expiresAt": "…", "hostedUrl": "https://verifia.anybanq.lk/s/vs_…", "embedUrl": "…" }
The workflow must be PUBLISHED. Other body fields: reusable (one link many people can each use) and neverExpires.
Browser
<script src="https://verifia.anybanq.lk/sdk/v1/anybanq.js"></script>
<script>
  AnyBanQ.verify.open({
    token: TOKEN_FROM_YOUR_SERVER,
    onComplete: function (r) {
      // r.status: APPROVED | REJECTED | FAILED | EXPIRED | MANUAL_REVIEW
      // r.approved: true only for APPROVED
    },
    onClose: function (r) {},
  });
</script>
Opens a modal. To render inside your own layout, use AnyBanQ.verify.mount("#element", { token, … }). The flow needs the camera for document and face capture.
Read the outcome from your server
// Sessions of a workflow, newest first, each with its status.
const sessions = await pay.verifications.listSessions(WORKFLOW_ID);
const mine = sessions.find((s) => s.id === session.id);
// mine.status: PENDING | ACTIVE | COMPLETED | EXPIRED

// Raw: GET https://verifia.anybanq.lk/v1/workflows/$WORKFLOW_ID/sessions

Webhook events

  • verification.completed
  • verification.reviewed

verification.completed is sent once for each check in the workflow as it finishes, with verificationId, type, status, outcome and score. verification.reviewed follows when a reviewer decides a check that was held for manual review. There is no single event for the whole session; read the session's status as shown above.

2 · Collection

Collect from a local bank account

You create a payment link or a bill; the customer pays it on our hosted pay page by linking their bank account and approving the debit. HNB JustPay is the only live rail, so today the paying account must be an HNB account.

Amounts in this section are numbers in major units, in LKR: 25000 is LKR 25,000.00. That is what these endpoints take. Remittance, below, uses strings of minor units instead.

Server · library
const link = await pay.collections.createPaymentLink({
  description: "Portfolio top-up",
  amount: 25000,               // LKR 25,000.00: major units, as a number
  customerName: "Nimal Perera",
  customerPhone: "0771234567", // we text the link unless notify is false
  expiresInDays: 7,            // default 30
});

link.url;    // send or show this to the customer
link.status; // unpaid | paid | expired | cancelled

// A payment link is a bill. Read it by the same id:
const bill = await pay.collections.getBill(link.id);
bill.status; // UNPAID | PAYING | HELD | PAID | OVERDUE | CANCELLED | SPLIT | REFUNDED
Server · raw HTTP
curl -X POST https://verifia.anybanq.lk/v1/payment-links \
  -H "Authorization: Bearer $ANYBANQ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "description": "Portfolio top-up",
    "amount": 25000,
    "customerName": "Nimal Perera",
    "customerPhone": "0771234567",
    "expiresInDays": 7
  }'

# { "id": "…", "ocr": "…", "payToken": "…", "url": "https://pay.anybanq.lk/pay/…",
#   "amount": 25000, "currency": "LKR", "status": "unpaid", "expiresAt": "…", "paidAt": null }

curl https://verifia.anybanq.lk/v1/bills/$ID -H "Authorization: Bearer $ANYBANQ_API_KEY"
There is no route for one payment link by id. GET /v1/payment-links lists them (status, page, pageSize); GET /v1/bills/:id reads the bill behind a link. To withdraw one: POST /v1/payment-links/:id/cancel.
A bill, when you know the payer
// A bill names the payer and a due date.
const bill = await pay.collections.createBill({
  description: "Advisory fee, October",
  amount: 15000,
  dueDate: "2026-11-01",
  payerNic: "199012345678",
  payerPhone: "0771234567",
  channels: ["sms"],
});

// Raw: POST https://verifia.anybanq.lk/v1/bills with the same fields.
Also: GET /v1/bills (status, payerNic, search, page, pageSize, sort) and POST /v1/bills/:id/cancel. Creating a bill or a link is not idempotent: if a create times out, look for the record before creating again.

A pay button on your page

For a fixed price, sign the product into a code once and put the code on a button. The code is safe to publish: the amount is inside it and signed, so editing the page cannot change what is charged.

Server, once
// Once, on your server: sign a product into a code. The price is inside it.
const { code } = await pay.collections.createPayButtonCode({
  description: "Portfolio top-up",
  amount: 25000,
});

// Raw: POST https://verifia.anybanq.lk/v1/payment-links/button
Browser
<script src="https://verifia.anybanq.lk/sdk/v1/anybanq.js"></script>

<!-- Any element. A click creates a fresh link and opens the hosted pay page. -->
<button data-verifia-pay="CODE_FROM_YOUR_SERVER">Top up LKR 25,000</button>
Add data-verifia-target="blank" to open the pay page in a new tab. After adding buttons to the page later, call AnyBanQ.pay.refresh().

Webhook events

  • bill.created
  • bill.paid
  • bill.scheduled
  • bill.split
  • bill.installments.scheduled
  • bill.autopay.stopped
  • bill.proof_submitted

bill.paid is the one to act on. It carries billId, ocr, amount, currency, settledVia, paymentRef and paidAt. A payment link sends the same events, because it is a bill; billId is the link's id.

3 · Remittance

Take a remittance

Remittance runs in test mode. The sender pays on a test card page, no card is charged and no recipient is paid. Orders carry sandbox: true and finish with payout.status of DRY_RUN. Where the test card page is not switched on, creating an order answers 503: “No collection rail is available for remittance orders”.

A sender abroad pays by card; the recipient is paid in LKR. You price the transfer with a quote, turn the quote into an order, and hand the browser the order's checkout token.

The card is charged in your organisation's charge currency: USD unless it was changed in the console. sourceCurrency must be that currency; a quote in any other is refused with 400. If the sender's card is in another currency, pass it as displayCurrency and the quote also carries indicative: the total in that currency, as an estimate to show. It is never what is charged.

Amounts are strings of minor units in both directions: "10000" is 100.00. Keep them as strings or integers in your own code. The library refuses a JavaScript number here so an amount never passes through a float.

Server · library
// 1. Price it. Amounts are strings of minor units: "10000" is USD 100.00.
const quote = await pay.remittance.createQuote({
  sourceCurrency: "USD",
  sendAmountMinor: "10000",   // or receiveAmountMinor, in LKR cents. Not both.
});
// quote.total, quote.receive, quote.rate, quote.rateExpiresAt

// 2. Make the order before the quote expires.
const order = await pay.remittance.createOrder({
  quoteId: quote.id,
  externalRef: "transfer-1001", // your own id. A retry with it returns this order.
  payee: {
    kind: "ACCOUNT",
    bankCode: "7083",
    accountNumber: "0010223348219",
    name: "Nimal Perera",
  },
  sender: { name: "Asha Perera", country: "GB", dob: "1990-04-12" },
});
// order.status: AWAITING_PAYMENT, or IN_REVIEW if screening held it

// 3. Give the page the checkout token and nothing else.
res.json({ token: order.checkoutToken });
Server · raw HTTP
curl -X POST https://verifia.anybanq.lk/v1/remittance/quotes \
  -H "Authorization: Bearer $ANYBANQ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "sourceCurrency": "USD", "sendAmountMinor": "10000" }'

# 201
# { "id": "…", "fixedSide": "SEND",
#   "send":    { "amountMinor": "10000", "currency": "USD" },
#   "fee":     { "amountMinor": "…",     "currency": "USD" },
#   "total":   { "amountMinor": "…",     "currency": "USD" },
#   "receive": { "amountMinor": "…",     "currency": "LKR" },
#   "rate": "…", "rateExpiresAt": "…" }

curl -X POST https://verifia.anybanq.lk/v1/remittance/orders \
  -H "Authorization: Bearer $ANYBANQ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "quoteId": "QUOTE_ID",
    "externalRef": "transfer-1001",
    "payee": { "kind": "ACCOUNT", "bankCode": "7083",
               "accountNumber": "0010223348219", "name": "Nimal Perera" },
    "sender": { "name": "Asha Perera", "country": "GB", "dob": "1990-04-12" }
  }'

# 201
# { "id": "…", "externalRef": "transfer-1001", "status": "AWAITING_PAYMENT",
#   "sandbox": true, "checkoutToken": "…", "checkoutUrl": "https://verifia.anybanq.lk/remit/…", … }
A second POST /v1/remittance/orders with the same externalRef and quoteId returns the same order with status 201 and creates nothing. The same externalRef with a different quote answers 409.

Who gets paid

kind: "ACCOUNT"

A Sri Lankan bank account you name: bankCode (the LankaPay code), accountNumber (5 to 20 digits) and name.

kind: "HANDLE"

A recipient’s remittance handle, sent as handle. We resolve it to the account its owner verified; send no account details.

kind: "FUND"

Your organisation’s own fund account, set in the console. Send nothing else. This is the one to use when customers are funding an account they hold with you.

The sender needs a name. country, nationality, dob, email and phone are optional, and a date of birth settles most borderline screening matches. If you verified the sender first, pass that session's id as verificationSessionId; it must be one of yours and completed.

Browser
<script src="https://verifia.anybanq.lk/sdk/v1/anybanq.js"></script>
<script>
  AnyBanQ.remit.open({
    token: CHECKOUT_TOKEN_FROM_YOUR_SERVER,
    onStatus: function (r) {},   // r.status, r.orderId
    onComplete: function (r) {
      // r.status: COMPLETED | FAILED | EXPIRED | CANCELLED
      // r.success: true only for COMPLETED
      // A prompt to refresh, not proof of payment. Confirm on your server.
    },
    onError: function (e) {},    // e.code, e.message
  });
</script>
Opens the checkout in a modal. The amount, the payee and the rate live in the order on our side; nothing on your page can change them. To redirect instead, send the customer to order.checkoutUrl.
Follow the order from your server
const latest = await pay.remittance.getOrder(order.id);
latest.status;        // see the table below
latest.sandbox;       // true: collected on the test card page, no money behind it
latest.payout.status; // DRY_RUN while real payouts are off

await pay.remittance.listOrders({ status: "COMPLETED", page: 1, pageSize: 50 });
await pay.remittance.cancelOrder(order.id); // only before the sender starts paying

// Raw:
//   GET  https://verifia.anybanq.lk/v1/remittance/orders/$ID
//   GET  https://verifia.anybanq.lk/v1/remittance/orders?status=COMPLETED&page=1&pageSize=50
//   POST https://verifia.anybanq.lk/v1/remittance/orders/$ID/cancel

Statuses and their events

StatusEventMeaning
IN_REVIEWremittance.order.in_reviewScreening held the sender or the payee. Someone on your team releases or rejects it in the console.
AWAITING_PAYMENTremittance.order.awaiting_paymentReady to be paid. Sent when a held order is released; a new clear order starts here with remittance.order.created.
PAYINGremittance.order.payingThe sender is on the card page. It can no longer be cancelled or expired.
COLLECTEDremittance.order.collectedThe bank authorised the card payment. The money has not arrived yet: card payments settle in bulk, usually one to two working days later.
SETTLEDremittance.order.settledThe bank's settlement file listed the payment. By default the payout is instructed only now. An organisation set to pay on collection skips this status; the event is still sent when its settlement arrives.
PAYOUT_INSTRUCTEDremittance.order.payout_instructedThe instruction to pay the recipient has been made.
COMPLETEDremittance.order.completedFinal. Read payoutStatus: DRY_RUN means no bank was instructed.
FAILEDremittance.order.failedFinal. failureReason says why.
EXPIREDremittance.order.expiredFinal. The quoted rate lapsed before the order was paid.
CANCELLEDremittance.order.cancelledFinal. You cancelled it, or a held order was rejected.

Every order also sends remittance.order.created when it is made. All ten events carry the same data: orderId, externalRef, status, sandbox, send, receive, payoutStatus, failureReason and occurredAt.

4 · Bulk payouts

Pay many accounts

An API key cannot approve a payout batch. Your system can post a batch and read it. Approving it takes a second person, signed in to the console, who is not whoever created it. The library has no approve method and the API answers 403 to a key that tries.

Real payouts are not live yet. While they are off, sending a batch records every row as DRY_RUN and moves nothing. Each batch says so itself: paymentsLive is false.

Server · library
const batch = await pay.payouts.createBatch({
  name: "October redemptions",
  rows: [
    { payeeName: "Nimal Perera", bankCode: "7010", accountNumber: "0012345678",
      amount: "85000.00", reference: "RED-1" },
    { payeeName: "Kamala Silva", bankCode: "7083", accountNumber: "0098765432",
      amount: "120000.00", reference: "RED-2" },
  ],
});

batch.status;           // AWAITING_APPROVAL
batch.fundingReference; // quote this on the bank transfer that funds the batch
batch.validCount;       // rows that will be paid
batch.invalidCount;     // rows left out, each with its reasons:
for (const row of batch.rows) {
  if (row.status === "INVALID") console.log(row.rowNumber, row.issues);
}

const latest = await pay.payouts.getBatch(batch.id);
const recent = await pay.payouts.listBatches(); // latest 100, without rows
Server · raw HTTP
curl -X POST https://verifia.anybanq.lk/v1/payout-batches \
  -H "Authorization: Bearer $ANYBANQ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "October redemptions",
    "rows": [
      { "payeeName": "Nimal Perera", "bankCode": "7010",
        "accountNumber": "0012345678", "amount": "85000.00", "reference": "RED-1" }
    ]
  }'

curl https://verifia.anybanq.lk/v1/payout-batches/$ID -H "Authorization: Bearer $ANYBANQ_API_KEY"
curl https://verifia.anybanq.lk/v1/payout-batches     -H "Authorization: Bearer $ANYBANQ_API_KEY"
Each row: payeeName, bankCode (the LankaPay code), accountNumber, amount as a string with at most two decimals, and an optional reference of up to 35 characters. Optional on the batch: name, amountMode (LKR or PAYER_CURRENCY) and payerCurrency.

A row that fails a check does not fail the call. It comes back with status: "INVALID" and its issues, and is left out of the total, so you fund only what is valid. There is no idempotency key: a second batch with the same payees, accounts and amounts is accepted and flagged with repeatOf, so check listBatches() before retrying a create that timed out.

What happens after you create it

  1. Step 1 · API key or console

    Create

    Every row is checked and its payee name screened. The batch is AWAITING_APPROVAL. Event: payout_batch.created.

  2. Step 2 · A second person, in the console

    Approve

    Never an API key, and never whoever created the batch. Events: payout_batch.approved, or payout_batch.rejected with a reason.

  3. Step 3 · Your bank transfer

    Fund

    One transfer for the batch total, quoting fundingReference. When the bank confirms it: payout_batch.funded.

  4. Step 4 · An approver, in the console

    Send

    Each ready row is instructed. When no row is left pending: payout_batch.completed, with counts of paid, failed, returned and notSent.

Batch statuses: AWAITING_APPROVAL, APPROVED, REJECTED, CANCELLED, PROCESSING, then PAID, PARTIALLY_PAID, FAILED or DRY_RUN. Row statuses: INVALID, READY, PROCESSING, PAID, FAILED, RETURNED, DRY_RUN.

Webhook events

  • payout_batch.created
  • payout_batch.approved
  • payout_batch.rejected
  • payout_batch.cancelled
  • payout_batch.funded
  • payout_batch.completed
  • payout_batch.row.returned

Each carries batchId. payout_batch.row.returned is sent when the bank sends a paid row back, with rowNumber, payoutReference, amountLkr and reason.

Webhooks

Check the signature, then act

Add an endpoint in the console under Webhooks and choose its events. The secret, which starts whsec_, is shown once. An endpoint receives only the events it lists. Endpoints cannot be added, changed or removed with an API key.

An event about a test record is delivered like any other and carries test: true in data. A live record's payload has no test field at all. Remittance events carry sandbox: true instead. Check the flag before you ship goods or credit an account.

What we send
POST https://your-app.example/hooks/anybanq
content-type: application/json
x-divp-event: remittance.order.completed
x-divp-signature: sha256=<hex HMAC-SHA256 of the raw body, keyed with your whsec_ secret>
x-divp-delivery-id: 0f2c6f0e-…        (the same on every retry)
x-divp-attempt: 1

{
  "event": "remittance.order.completed",
  "data": {
    "orderId": "…",
    "externalRef": "transfer-1001",
    "status": "COMPLETED",
    "sandbox": true,
    "send":    { "amountMinor": "10000", "currency": "USD" },
    "receive": { "amountMinor": "…",     "currency": "LKR" },
    "payoutStatus": "DRY_RUN",
    "failureReason": null,
    "occurredAt": "…"
  },
  "sentAt": "2026-10-10T08:00:00.000Z"
}
Always a POST with a JSON body of event, data and sentAt. We do not follow redirects, and we wait five seconds for any 2xx answer.

The signature

  • Header: x-divp-signature, with the value sha256= followed by 64 hex characters.
  • Algorithm: HMAC-SHA256, keyed with your webhook secret.
  • What is signed: the raw request body, byte for byte. Nothing else. A body your framework parsed and you turned back into JSON will not match.
  • There is no timestamp header. The time is sentAt inside the signed body: the moment the event was queued. It is the same on every retry, so a retry has the same signature as the first attempt.
  • Replays: x-divp-delivery-id is the same on every retry of one delivery. Store it and skip a delivery you have already handled.
Handler · library
import express from "express";
import { verifyWebhook, AnybanqWebhookError } from "./anybanq-pay.mjs";

const app = express();

// The raw bytes, not parsed JSON: the signature covers the body exactly as sent.
app.post("/hooks/anybanq", express.raw({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    event = verifyWebhook(req.body, req.headers, process.env.ANYBANQ_WEBHOOK_SECRET);
  } catch (err) {
    if (err instanceof AnybanqWebhookError) return res.status(400).send(err.reason);
    throw err;
  }

  // A retry resends the same delivery. Skip one you have already handled.
  if (event.deliveryId && (await alreadyHandled(event.deliveryId))) return res.sendStatus(200);

  switch (event.event) {
    case "remittance.order.completed":
      await markTransferDone(event.data.externalRef);
      break;
    case "bill.paid":
      await markInvoicePaid(event.data.billId, event.data.amount);
      break;
    case "payout_batch.completed":
      await recordBatch(event.data.batchId, event.data.status);
      break;
    case "verification.completed":
      await recordCheck(event.data.verificationId, event.data.status);
      break;
  }
  res.sendStatus(200);
});
verifyWebhook throws AnybanqWebhookError with reason missing_signature, bad_signature, malformed_body or stale_timestamp. pay.webhooks.verify is the same function.

By default the library refuses a delivery whose sentAt is more than an hour old. That accepts every automatic retry. If you replay an older delivery from the console on purpose, pass { toleranceSeconds } as the fourth argument.

Without the library
import crypto from "node:crypto";

const expected = "sha256=" + crypto
  .createHmac("sha256", process.env.ANYBANQ_WEBHOOK_SECRET)
  .update(rawBody) // the exact bytes received
  .digest("hex");

const given = req.headers["x-divp-signature"] ?? "";
const ok =
  given.length === expected.length &&
  crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));

A delivery that fails is retried after 10 seconds, 30 seconds, 2 minutes, 10 minutes and 30 minutes: six attempts over about 43 minutes. After that it waits in the console's delivery list for you to replay. Send a webhook.test event from the console to prove your endpoint before anything real depends on it.

Errors

Refused is not the same as unanswered

The API answers errors as JSON with statusCode, message and error. message is a sentence, or a list of sentences when several fields failed validation. The usual ones: 400 for a bad request, 401 for a bad key, 403 for a missing scope, a service that is off or an address not on your whitelist, 404 for an id that is not yours, 409 for a conflict such as a reused externalRef, 429 when you are over a rate limit.

AnybanqApiError

The API answered and refused. status, message, details, body, and requestId when a response carries an x-request-id header (ours do not today).

AnybanqOutcomeUnknownError

A create or cancel was sent and no answer came back. Not a failure: the work may have been done.

AnybanqConnectionError

A read got no answer. Nothing changed; try again.

AnybanqWebhookError

A webhook delivery failed verification. Answer 400 and do nothing else.

When a create times out
import { AnybanqApiError, AnybanqOutcomeUnknownError } from "./anybanq-pay.mjs";

try {
  order = await pay.remittance.createOrder(input);
} catch (err) {
  if (err instanceof AnybanqOutcomeUnknownError) {
    // Sent, no answer. The order may exist. Same externalRef, so this is safe:
    order = await pay.remittance.createOrder(input);
  } else if (err instanceof AnybanqApiError) {
    // The API refused it. err.status, err.message (the API's own words).
    throw err;
  }
}
Safe for createOrder because externalRef makes it idempotent. createBatch, createBill and createPaymentLink have no such key: look the record up before creating again.

Test mode

What “test” means, exactly

There are two different things called test here. Keep them apart.

divp_test_… keys

  • Work before your account is approved.
  • Are exempt from your IP whitelist.
  • Are counted separately in your usage figures.
  • Make test records. A bill, a payment link, a subscription plan or a payout batch made with a test key carries test: true and is kept apart from your live records. A test key lists test records only; a live key and the console list live ones only.
  • Reach nobody and move nothing. No text or email leaves for a test record, no bank account can be linked or debited, and a test batch is never approved, funded or sent.
  • Make sandbox remittance orders only, or none where the test card page is off.

Services in test mode

  • Set by us for the whole platform, not by the key you use.
  • Remittance: the order is marked sandbox: true and collected on a test card page.
  • Payouts: rows and remittance payouts are recorded as DRY_RUN; paymentsLive is false on a batch.
  • The same is true with a live key until we switch these on.

Verification works the same way. A check run with a test key, or a step of a hosted verification started with one, is a test record marked test: true. No provider is contacted, nobody is messaged and nothing is metered. A one-time code comes back in the response as devCode, a bank account step accepts 123456, and the last four digits of the NIC or account number choose the outcome. A test check tells you nothing about a real person.

What each thing does with a test key

  • Bill, payment link, pay button

    Marked test: true. Nobody is texted: smsDelivery comes back { sent: false, suppressed: true } and the text is kept under GET /v1/notifications for the test key. The pay page says it is a test and offers a simulated payment only.

  • Subscription plan

    Marked test: true. A subscriber signs up without linking an account. Each cycle raises a test bill; nothing is debited and nobody is messaged.

  • Payout batch

    Marked test: true. Nobody can approve or fund it and it is never sent. POST /v1/payout-batches/:id/simulate completes it as DRY_RUN.

  • Remittance order

    Always sandbox: true, on the test card page. Where that page is off the create call answers 403 and creates nothing. A HANDLE payee is refused, because handles belong to real people: use ACCOUNT or FUND.

  • Webhooks

    Still sent, with test: true in data (remittance: sandbox: true).

Read the record instead of assuming: bill.test, batch.test, order.sandbox, order.payout.status, batch.paymentsLive and each row's status say what actually happened.

Pay a test bill without clicking

In the browser, the pay page of a test bill has one button: simulate the payment. For an automated test, make the same call. With your test key, POST /v1/bills/:id/pay does the same.

A test bill, start to finish
# A bill made with a divp_test_ key comes back marked, and nobody is texted:
# { "id": "…", "payToken": "…", "test": true,
#   "smsDelivery": { "sent": false, "suppressed": true, "destination": "*******000", … } }

# Read the text that was not sent (test key):
curl https://verifia.anybanq.lk/v1/notifications -H "Authorization: Bearer $ANYBANQ_TEST_KEY"

# Pay it. No API key: the pay token is the credential. Answers 404 for a real bill.
curl -X POST https://verifia.anybanq.lk/v1/public/bills/$PAY_TOKEN/test-pay

# { "status": "PAID", "settledVia": "TEST", "test": true, … }
# and bill.paid is delivered with "test": true in data.
Complete a test payout batch
# Run a test batch to its end. Test key only; nothing is approved, funded or sent.
curl -X POST https://verifia.anybanq.lk/v1/payout-batches/$BATCH_ID/simulate   -H "Authorization: Bearer $ANYBANQ_TEST_KEY"

# { "status": "DRY_RUN", "test": true, "paymentsLive": false, "rows": [ { "status": "DRY_RUN", … } ] }
# and payout_batch.completed is delivered with "test": true in data.

Finish a test remittance without clicking

In the browser, the test card page has an approve and a decline button. For an automated test, drive the same two calls the checkout makes.

Complete a sandbox order
# 1. Start the payment, as the checkout page does. No API key: the token is the credential.
curl -X POST https://verifia.anybanq.lk/v1/public/remittance/checkout/$CHECKOUT_TOKEN/pay

# 2. Decide it. Answers 404 unless the order has "sandbox": true.
curl -X POST https://verifia.anybanq.lk/v1/public/remittance/checkout/$CHECKOUT_TOKEN/mock/complete \
  -H "Content-Type: application/json" \
  -d '{ "outcome": "APPROVED" }'   # or "DECLINED"

# { "status": "COMPLETED" }
Then read the order with your key, or wait for remittance.order.completed or remittance.order.failed.

Limits and rules

What a key can and cannot do

  • An API key cannot approve, reject or send a payout batch.

    POST /v1/payout-batches/:id/approve answers 403 to a key. Approval needs a signed-in person who did not create the batch.

  • An API key cannot change remittance fees or margin.

    PUT /v1/remittance/settings answers 403 to a key: “Remittance settings can only be changed from the console”. The key that creates orders cannot reprice them.

  • An API key cannot create another API key.

    Keys, the IP whitelist and the public key are managed in the console. Every key is refused, scoped or not: 403, “API keys and API security can only be managed from the console”.

  • An API key cannot manage webhooks.

    Endpoints are added, changed and removed in the console. POST /v1/webhooks answers 403 to every key: “Webhooks can only be managed from the console”. A key that could repoint an endpoint could send your events somewhere else.

  • An API key cannot change your account.

    Team members, roles, embed domains and other organisation settings answer 403 to every key. They are changed by a person signed in to the console.

  • A scoped key reaches only what its scopes cover.

    This holds for every route, bills and payment links included. A key scoped to verifications cannot issue a bill or create a remittance order. A key with no scopes is not narrowed.

  • A test key makes test records and sees only those.

    Bills, payment links, subscription plans and payout batches made with a divp_test_ key carry test: true, reach nobody and move nothing. A test key cannot read or change a live record, and a live key cannot read a test one.

  • A FUND payee is fixed.

    payee: { kind: "FUND" } pays your organisation’s own fund account, set in the console. Sending a bank, account, name or handle with it is refused, not ignored.

  • One quote makes one order.

    A quote holds its rate for five minutes. A used or expired quote is refused; ask for a new one.

  • A checkout token lasts 24 hours.

    After that the hosted checkout answers as if the order did not exist. The order itself expires sooner, with its quote, if nobody has started paying.

  • A payout batch holds up to 500 rows.

    One row can carry at most LKR 5,000,000. Creating a batch is limited to five calls a minute, because each call screens every name.

  • Lists are paged.

    pageSize goes up to 100 for remittance orders, bills and payment links. GET /v1/payout-batches returns the latest 100 batches and takes no parameters.

  • A live key can be tied to your addresses.

    If you set an IP whitelist under API keys, a live key used from anywhere else answers 403. Test keys are exempt.

Services and scopes for each area

A service must be on for your organisation, and a scoped key must hold the scope. When a service is off the API answers 403 with “The … service is not enabled for your account”. When a scope is missing it answers 403 with “API key is not scoped for: …”.

AreaService that must be onKey scope
Verify a customerThe checks in your workflowverifications
Collect from a bank accountBill issuedbills
Take a remittanceRemittance orders (off until we switch it on)remittance
Pay many accountsBulk payouts (off until we switch it on)payouts
Register webhooksAlways onNo key. Console only.

Remittance and bulk payouts are off for every organisation until we switch them on. Ask your AnyBanQ contact, and tell us which you need.

Next

Start with a test key.

Registering gives you a divp_test_ key straight away. The full endpoint reference is in the console once you can sign in; it is not published on the open web.