OnLink
Guides

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
    }
  }
}
  • channel is webhook, and deliveredTo is the host of your webhook URL, never the full URL. The other channels are email and sms.
  • webhookDelivery appears only on the webhook channel. status is pending while we are still trying, delivered once your endpoint answered 2xx, and failed once every attempt has failed, or once we stopped pushing because your webhook settings changed. attempts counts the pushes made so far, and lastAttemptAt is null until 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 with GET /v1/transfers/{id} while the instruction is awaiting_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 idempotencyKey you 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 2xx as 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.

  1. Verify the signature over the raw body, with the signing secret we gave you. An event that fails is not from us. Return 401 and read nothing from it.
  2. Dedupe on X-OnLink-Delivery. A retry reuses the delivery id, so a repeat means your first 2xx did not reach us. Acknowledge it and do nothing.
  3. 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.
  4. 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 the 202. If you already hold it, it must equal data.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 202 tells you which items were accepted (items[].accepted, with each accepted item's transferId) but carries no amount, fee or total. So: take each item's amount from what you submitted, read its fee from GET /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.
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 2xx within 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 301 or 302 is a failure.
  • Your webhook URL or secret changed. If it stopped being valid while your account is switched, creating instructions fails with CONFIRMATION_DELIVERY_UNAVAILABLE until 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.

On this page