Auto-confirm instructions
Have confirmation codes pushed to your signed webhook and confirm an instruction from your own code: verify, dedupe, match, confirm. What changes, what to check, and what to do when a push does not arrive.
Every transfer, USDT send and batch needs a one-time code before it moves
anything. By default that code goes to the confirmation contact registered on your
partner account, and whoever holds that contact reads it out to your integration.
If you would rather
confirm from code, we can push the code to your webhook endpoint instead, as the
signed confirmation.code_issued event.
This guide is for that setup. If your account has not been switched, none of it
applies: you will see no confirmation.code_issued event, and
Send money from your KES account
is the flow you follow.
When to use it
Ask OnLink to switch your account when the person reading codes out is the slow or error-prone step, and you already run a webhook endpoint you trust.
Switching changes who receives the code. Before, it was your registered confirmation contact. After, it is your webhook endpoint, and nobody else. A code that reaches your endpoint can confirm an instruction, so your endpoint is now the second factor — read Security before you ask.
Three things stay the same: the code is 6 digits, it is valid for 5 minutes, and
five wrong attempts lock the confirmation. We can switch you only when your
account has a webhook URL, a signing secret and a URL that passes our checks, and
we check that before we switch you. While your account is switched, creating an
instruction without a working webhook fails with
CONFIRMATION_DELIVERY_UNAVAILABLE and creates nothing — see
Errors.
What the create response tells you
Create the instruction exactly as before. The confirmation block now says the
code went to your webhook, and says how the push is going.
Response — 202 Accepted:
{
"id": "bd9f1e6a-0000-4000-8000-000000000010",
"status": "awaiting_confirmation",
"rail": "mpesa",
"amount": { "currency": "KES", "value": "1500.00" },
"fee": { "currency": "KES", "value": "10.00" },
"destination": {
"accountNumber": "254700000000",
"accountName": "Jane Wanjiku"
},
"nameVerification": "verified",
"verifiedAccountName": "JANE WANJIKU",
"partnerReference": "ACME-TRF-000123",
"createdAt": "2026-09-11T00:00:00.000Z",
"confirmation": {
"id": "df9f1e6a-0000-4000-8000-000000000030",
"channel": "webhook",
"deliveredTo": "hooks.partner.co.ke",
"expiresAt": "2026-09-11T00:05:00.000Z",
"resendAvailableAt": "2026-09-11T00:00:15.000Z",
"webhookDelivery": {
"status": "pending",
"attempts": 0,
"lastAttemptAt": null
}
}
}channeliswebhook, anddeliveredTois the host of your webhook URL, never the full URL. The other channels areemailandsms.webhookDeliveryappears only on thewebhookchannel.statusispendingwhile we are still trying,deliveredonce your endpoint answered2xx, andfailedonce every attempt has failed, or once we stopped pushing because your webhook settings changed.attemptscounts the pushes made so far, andlastAttemptAtisnulluntil the first one.- We push in the background, so this response does not wait for it. It usually
reads
pending. Read the same block again withGET /v1/transfers/{id}while the instruction isawaiting_confirmation. - The push can reach your endpoint before this response reaches you. Do not
depend on having stored the confirmation id first. Match the event to your
instruction by the
idempotencyKeyyou chose, as the handler below does.
The event you receive
We POST the code to your webhook, signed exactly like every other delivery: the
same four X-OnLink-* headers and the same string to sign. See
Verifying signatures.
{
"id": "4a2d7e91-0000-4000-8000-000000000045",
"type": "confirmation.code_issued",
"createdAt": "2026-09-11T00:00:00.412Z",
"data": {
"confirmationId": "df9f1e6a-0000-4000-8000-000000000030",
"subjectType": "transfer",
"subjectId": "bd9f1e6a-0000-4000-8000-000000000010",
"idempotencyKey": "trf-2026-09-11-000123",
"partnerReference": "ACME-TRF-000123",
"amount": { "currency": "KES", "value": "1500.00" },
"code": "482913",
"expiresAt": "2026-09-11T00:05:00.000Z"
}
}Every field is described on Webhooks. Two things differ from every other event:
- It carries a live code. Verify the signature before you read
data.code, and never log the code or the body. - It is never stored and never redelivered later. We make up to three attempts,
all over within about 35 seconds, and each attempt waits at most 5 seconds for
your answer. After that we stop. Keep your endpoint quick: return
2xxas soon as you have recorded the delivery, then do the rest.
Verify, dedupe, match, confirm
Four checks sit between the event and the confirm call, in this order. Skip none of them: each one stops a different mistake.
- Verify the signature over the raw body, with the signing secret we gave you.
An event that fails is not from us. Return
401and read nothing from it. - Dedupe on
X-OnLink-Delivery. A retry reuses the delivery id, so a repeat means your first2xxdid not reach us. Acknowledge it and do nothing. - Match the event to an instruction you actually created, and check the amount. See below. A code for an instruction you did not create, or for an amount you did not ask for, must not be confirmed.
- Confirm with
POST /v1/confirmations/{confirmationId}and{ "code": data.code }, signed with your API credentials.
Matching: what to record, and when
Record the instruction before you send the create request, not after the
response. The first push is sent while the create call is still being answered, so
it can reach your endpoint before the 202 reaches you, and your handler must be
able to match it without anything from that response.
Key your record on subjectType plus idempotencyKey, both of which you know
before you call. Transfers, USDT sends and batches each keep their own idempotency
key space, so the same key string can legitimately appear on a transfer and on a
batch. The event carries both (data.subjectType is transfer, usdt_send or
batch), so the lookup is exact.
Then check, in this order:
subjectId— you do not know it until the202. If you already hold it, it must equaldata.subjectId. If you do not yet, take it from the event and keep it.amount— compare in minor units, not as strings. A decimal string has a fixed number of places for its currency: KES 2, USDT 6. We always send that form, so"1500.00"is what you will receive even if you created the instruction with"1500". Convert both sides to whole minor units (cents, or millionths of a USDT) and compare integers. The amount is:- for a transfer, the transfer's
amount, without its fee; - for a USDT send, the send's
usdtAmount; - for a batch, the sum of each accepted item's amount plus its fee. The
202tells you which items were accepted (items[].accepted, with each accepted item'stransferId) but carries no amount, fee or total. So: take each item's amount from what you submitted, read itsfeefromGET /v1/transfers/{transferId}for every accepted item, and sum both in minor units. Record the batch with its per-item amounts and no total, hold the event until you have the total, then compare.
- for a transfer, the transfer's
const crypto = require('node:crypto');
const express = require('express');
const { confirmCode, getTransfer } = require('./onlink-client');
const WEBHOOK_SECRET = process.env.ONLINK_WEBHOOK_SECRET;
// Stand-ins for your own storage. Use durable stores in production.
const seenDeliveries = new Set();
// `${subjectType}:${idempotencyKey}` -> { subjectId, amountMinor, itemAmounts }
const instructions = new Map();
const parked = new Map(); // a batch's event, held until its total is known
// Decimal places per currency. Compare minor units, never strings.
const PLACES = { KES: 2, USDT: 6 };
function toMinor({ currency, value }) {
const places = PLACES[currency];
const [whole, fraction = ''] = value.split('.');
if (places === undefined || fraction.length > places) {
throw new Error(`cannot read ${currency} amount`);
}
return BigInt(whole + fraction.padEnd(places, '0'));
}
// Call this BEFORE you send the create request: the push can beat the 202.
// For a batch pass amount: null and the amount you submitted for each item.
function rememberInstruction({ subjectType, idempotencyKey, amount, items }) {
instructions.set(`${subjectType}:${idempotencyKey}`, {
subjectId: null,
amountMinor: amount === null ? null : toMinor(amount),
itemAmounts: new Map(
(items ?? []).map((i) => [i.idempotencyKey, toMinor(i.amount)]),
),
});
}
// A batch's amount is the sum of each accepted item's amount plus its fee. The
// 202 says which items were accepted but carries no fee, so read each accepted
// item's fee from its transfer.
async function batchAmountMinor(entry, items) {
let total = 0n;
for (const item of items.filter((i) => i.accepted)) {
const { status, body } = await getTransfer(item.transferId);
if (status !== 200) throw new Error('cannot read an accepted transfer');
total += entry.itemAmounts.get(item.idempotencyKey) + toMinor(body.fee);
}
return total;
}
// Call this with the 202 (items is the batch response's items, for a batch).
async function recordCreated({
subjectType,
idempotencyKey,
subjectId,
items,
}) {
const key = `${subjectType}:${idempotencyKey}`;
const entry = instructions.get(key);
entry.subjectId = subjectId;
if (subjectType === 'batch') {
entry.amountMinor = await batchAmountMinor(entry, items);
}
const data = parked.get(key);
if (data !== undefined) {
parked.delete(key);
if (check(entry, data) === 'ok') submit(data);
}
}
function check(entry, data) {
if (entry === undefined) return 'unknown';
if (entry.subjectId !== null && entry.subjectId !== data.subjectId) {
return 'mismatch';
}
if (entry.amountMinor === null || entry.amountMinor === undefined) {
return 'wait';
}
try {
return toMinor(data.amount) === entry.amountMinor ? 'ok' : 'mismatch';
} catch {
return 'mismatch'; // an amount you cannot read is not one to confirm
}
}
function submit(data) {
confirmCode(data.confirmationId, data.code)
.then(({ status }) => {
console.log('confirmation answered', {
confirmationId: data.confirmationId,
status,
});
})
.catch((error) => {
// Ids only. Never the code, and never the whole error object.
console.error('confirmation request failed', {
confirmationId: data.confirmationId,
error: error.name,
});
});
}
function verifyOnLinkWebhook({ secret, path, headers, rawBody }) {
const received = headers['x-onlink-signature'];
const timestamp = headers['x-onlink-timestamp'];
const deliveryId = headers['x-onlink-delivery'];
if (!received || !timestamp || !deliveryId) return false;
if (Math.abs(Date.now() - Number(timestamp)) > 300_000) return false;
const bodyHash = crypto.createHash('sha256').update(rawBody).digest('hex');
const signingString = ['POST', path, timestamp, deliveryId, bodyHash].join(
'\n',
);
const expected =
'v1=' +
crypto.createHmac('sha256', secret).update(signingString).digest('hex');
const a = Buffer.from(received);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
app.post(
'/hooks/onlink',
// The signature is over the raw bytes, so keep them: no JSON parsing first.
express.raw({ type: 'application/json' }),
(req, res) => {
// 1. Verify. Nothing in an unverified body is read, least of all the code.
const verified = verifyOnLinkWebhook({
secret: WEBHOOK_SECRET,
path: req.originalUrl,
headers: req.headers,
rawBody: req.body,
});
if (!verified) return res.sendStatus(401);
// 2. Dedupe on the delivery id, and record it before acknowledging.
const deliveryId = req.headers['x-onlink-delivery'];
if (seenDeliveries.has(deliveryId)) return res.sendStatus(200);
seenDeliveries.add(deliveryId);
const event = JSON.parse(req.body.toString('utf8'));
if (event.type !== 'confirmation.code_issued') return res.sendStatus(200);
// Acknowledge now. We wait 5 seconds, and the rest is your own work.
res.sendStatus(200);
const { data } = event;
// 3. Match it to an instruction you created, for the amount you asked for.
const key = `${data.subjectType}:${data.idempotencyKey}`;
const entry = instructions.get(key);
const verdict = check(entry, data);
if (verdict === 'wait') {
parked.set(key, data); // a batch: recordCreated() finishes the job
// once it has added up the accepted items' amounts and fees.
return;
}
if (verdict !== 'ok') {
// Log the ids, never the code. Someone should look at this.
console.error('confirmation code does not match an instruction', {
confirmationId: data.confirmationId,
idempotencyKey: data.idempotencyKey,
});
return;
}
entry.subjectId = data.subjectId; // the 202 may not have reached you yet
// 4. Confirm.
submit(data);
},
);
app.listen(3000);The handler above is the shape, not a drop-in: your lookup, your storage and your
error handling replace the stand-ins. Three details are deliberate. It records the
delivery id before it acknowledges, so a retry that arrives while you are still
working is a no-op. It converts amounts to whole minor units with BigInt, never a
float, and compares those — see Money. And a parked batch
event keeps its code in memory only until its total is known: drop anything parked
for longer than the 5 minutes the code is valid.
The confirm call
The handler uses two signed calls, both made by the small client below: POST /v1/confirmations/{id} with the code, which is the same call the transfers
guide makes,
and GET /v1/transfers/{id}, which a batch needs to read each accepted item's fee.
GET /v1/transfers/{id} is signed with an empty body, exactly as
Authentication describes, and needs the
transfers:read scope on the key the handler uses. Save the client as
onlink-client.js.
const crypto = require('node:crypto');
const HOST = 'https://sandbox.onlink.africa';
// Signs and sends one request. payload is undefined for a GET.
async function signedRequest(method, pathAndQuery, payload) {
// Serialised once, then both hashed and sent as this exact string.
const body = payload === undefined ? '' : JSON.stringify(payload);
const timestamp = String(Date.now());
const nonce = crypto.randomUUID();
const bodySha256Hex = crypto.createHash('sha256').update(body).digest('hex');
const signature = crypto
.createHmac('sha256', process.env.ONLINK_SECRET)
.update([method, pathAndQuery, timestamp, nonce, bodySha256Hex].join('\n'))
.digest('hex');
const headers = {
'X-OnLink-Key': process.env.ONLINK_KEY_ID,
'X-OnLink-Timestamp': timestamp,
'X-OnLink-Nonce': nonce,
'X-OnLink-Signature': `v1=${signature}`,
};
if (payload !== undefined) headers['Content-Type'] = 'application/json';
const response = await fetch(HOST + pathAndQuery, {
method,
...(payload === undefined ? {} : { body }),
headers,
});
return { status: response.status, body: await response.json() };
}
const confirmCode = (confirmationId, code) =>
signedRequest('POST', `/v1/confirmations/${confirmationId}`, { code });
const getTransfer = (transferId) =>
signedRequest('GET', `/v1/transfers/${transferId}`);
module.exports = { confirmCode, getTransfer };
// Run this file directly to try it with the example values.
if (require.main === module) {
confirmCode('df9f1e6a-0000-4000-8000-000000000030', '482913').then(
console.log,
);
}Response — 200 OK:
{
"confirmationId": "df9f1e6a-0000-4000-8000-000000000030",
"subjectType": "transfer",
"subjectId": "bd9f1e6a-0000-4000-8000-000000000010",
"status": "confirmed",
"verifiedAt": "2026-09-11T00:02:00.000Z"
}status: "confirmed" means the code was right and execution has been handed off.
It is not a settlement signal: wait for transfer.settled or transfer.failed, as
on every other path.
If the push never arrives
We stop pushing after about 35 seconds, and a code we could not deliver is gone.
You will see it as webhookDelivery.status: "failed" on the instruction's
confirmation block, or as an instruction still awaiting_confirmation with no
event having reached you.
Call POST /v1/confirmations/{id}/resend. It issues a new code and pushes it
under a new X-OnLink-Delivery id; the old code stops working the moment the
new one is issued. The response carries the new push's webhookDelivery, so you can
tell whether it is under way. Resends are on a cooldown ladder — see
Caps and limits.
A new delivery id after a resend is not a duplicate of the first. Do not drop it
because you recognise the confirmationId. Dedupe on the delivery id, as above,
and let a resend through.
A CONFIRMATION_CODE_INVALID straight after a resend may mean you confirmed with the
code the resend replaced: a push of the old code can still reach you after the resend
was issued. Wait for the newer delivery and confirm with its code. Do not retry in a
loop: each wrong code counts towards the five attempts that lock the confirmation.
A resend goes wherever your account sends codes at that moment. If we have switched your account back to your registered confirmation contact, the new code goes to that person, not to your endpoint.
A push fails for a reason you can usually fix. Check, in this order:
- Your endpoint did not answer
2xxwithin 5 seconds. A slow handler is the usual cause. Acknowledge first, work afterwards. - Your signature check rejects us. Compare your string to sign field by field against Verifying signatures, and check you are using your webhook signing secret rather than your API secret.
- Your endpoint redirected. We never follow redirects, so a
301or302is a failure. - Your webhook URL or secret changed. If it stopped being valid while your
account is switched, creating instructions fails with
CONFIRMATION_DELIVERY_UNAVAILABLEuntil you ask us to fix it. Contact OnLink support.
If the code is no longer wanted, let it expire. You receive confirmation.expired
and the instruction moves to expired.
Security
The code is the second factor on a money-moving instruction. Once your account is switched, the factor is your webhook endpoint, and whoever controls the endpoint controls confirmation.
This is a trade-off, and it is yours to weigh. With a registered contact, a stolen API credential cannot move money on its own, because someone still has to read out a code. With codes by webhook, a single backend that holds both your API secret and your webhook endpoint holds both factors, so a compromise of that backend can move money with nobody reading out a code. Keep the two apart where you can, and protect the endpoint like a credential:
- Verify every delivery, with the raw body and in constant time, before you read anything from it. An endpoint that confirms on an unverified body lets anyone who finds the URL post a code of their choosing.
- Never log a code, and never log the whole body of this event. Logs are copied, retained and read by people who should not hold a live code. Log the confirmation id and the delivery id instead.
- Check the amount and the instruction before you confirm, as above. This is what stops a confirm that your own system did not intend, whoever caused it.
- Keep the signing secret out of source control, and tell us at once if it leaks. We do not rotate or clear a webhook secret while your account is switched, because that would leave codes with nowhere to go. We switch you back to your registered confirmation contact first, rotate the secret, then switch you again.
- Do not forward the event to a queue, a chat channel or a third party that your own access rules do not cover.
If you suspect the endpoint is compromised, ask us to switch your account back to your registered confirmation contact. The next code goes to that person, a resend of a code already issued goes there too, and we stop any push still being retried, so the endpoint no longer sees one. A code the endpoint already received stays valid until it is replaced or expires, 5 minutes after it was issued. Resend each confirmation still pending: the new code goes to your contact and the one the endpoint holds stops working. For a transfer we can also cancel the instruction if you tell us which.
Send a batch of transfers
Submit N transfers in one call, consent to the whole batch with a single code, and get a per-item outcome. A loop over the single-transfer behaviour behind one confirmation.
Send USDT from your wallet
Create an instruction, your registered person confirms it with a one-time code, then it executes. One rail, one consent step, one honest failure model.