Skip to content

Callbacks & Status ​

Every gateway notifies your server of the payment result. handleCallback takes a CallbackRequest, verifies the gateway's signature and returns a PaymentCallback. It is synchronous, never awaited: verifying needs no network call.

Production setup

For production, follow Handling Webhooks (recommended): verify, store the call, acknowledge immediately, then process it once in the background with retries. The example below handles everything inline to show the API.

Every callback goes through the same steps; KBZ Pay is shown here.

Handling a KBZ Pay callbackYour appKBZ PayPayment notificationPOST to callbackUrlVerify the signaturekbz.handleCallback(request)Invalid: 400, never fulfillSignatureVerificationErrorFind the orderby callback.orderIdFulfill onceskip if paid, match the amountAcknowledge: plain successcallback.acknowledgement.send(res)No acknowledgement? Retryafter 60 s, then 600 s
Handling a KBZ Pay callback
ts
import type { IncomingMessage, ServerResponse } from 'node:http';
import {
  Amount,
  CallbackRequest,
  SignatureVerificationError,
  type PaymentCallback,
} from '@laranex/myanmar-payments';

import { orders } from './orders.js';

async function kbzCallback(
  req: IncomingMessage,
  res: ServerResponse,
): Promise<void> {
  const request = await CallbackRequest.fromNodeRequest(req);
  let callback: PaymentCallback;
  try {
    callback = kbz.handleCallback(request);
  } catch (error) {
    if (!(error instanceof SignatureVerificationError)) throw error;
    console.warn(`rejected KBZ callback: ${error.message}`);
    res.writeHead(400).end('invalid callback');
    return;
  }

  await orders.transaction(async (tx) => {
    const order = await tx.findForUpdate(callback.orderId);
    const paid = Amount.parse(order.amount).equals(callback.amount);
    if (callback.isSuccessful() && order.paidAt === null && paid) {
      await tx.markPaid(order, callback.gatewayReference);
    }
  });

  // KBZ Pay: HTTP 200 with plain-text "success"
  callback.acknowledgement.send(res);
}

Building a CallbackRequest ​

Signatures are checked against what the gateway actually sent, so build the request from the real incoming request: the raw body, the headers and the query string. Never rebuild it from parsed input such as Express's req.body after express.json(): a JSON number such as 1000.50 would come back as 1000.5 and break a signature over the exact text.

FactoryUse when
await CallbackRequest.fromNodeRequest(req)node:http, Express (req), Koa (ctx.req, without a body parser). Reads the raw body from the stream, or from req.rawBody / req.body when middleware captured it as text or bytes
await CallbackRequest.fromWebRequest(request)A Fetch API Request: Next.js route handlers, Hono, Bun, Deno, Cloudflare Workers. Reads a clone, so the request stays readable
CallbackRequest.from({ body, headers, query })Every other server: pass the raw body (string, Buffer, Uint8Array or ArrayBuffer), the headers (an object or [name, value] pairs) and the query string (string, URLSearchParams or an object). All three are optional
CallbackRequest.fromJson(payload, headers?)Replaying a payload you stored as decoded JSON, e.g. from a queue or a failed-callback table
Frameworkbodyheadersquery
node:http, ExpressfromNodeRequest(req) reads all three
Fastifyrequest.body (kept as a string, see Fastify)request.headersrequest.query
Next.js, Hono, Bun, DenofromWebRequest(request) reads all three

When a body parser such as express.json() already consumed the stream, fromNodeRequest encodes the parsed req.body again as JSON or a form. That usually verifies, but a JSON number such as 1000.50 comes back as 1000.5, so prefer express.raw() on callback routes.

MemberDescription
rawBodyThe raw body as a Uint8Array, exactly as received
bodyThe raw body, decoded as UTF-8
headersHeaders with lowercase names; repeated headers are joined with ,
queryQuery string values (the first of each)
header(name)One header, case-insensitively, or undefined
parsedBody()The body decoded as JSON or a urlencoded form; JSON numbers keep their exact text as strings (1000.50 stays "1000.50")
input()The parsed body merged over the query string
queryInput()The query string merged over the parsed body

Rules ​

  • Verify, then trust. A callback that fails verification throws SignatureVerificationError, and so does one whose signed or hashed field holds an object or array instead of a single value, since no gateway signs nested values. Never act on its payload; raw carries the unverified data for logging only.
  • Check the amount. Compare callback.amount (the exact text the gateway sent) with your order before fulfilling, e.g. with Amount.equals.
  • Be idempotent. Gateways retry and may deliver the same callback more than once.
  • Acknowledge. callback.acknowledgement holds the response the gateway expects (status, body, headers), e.g. KBZ Pay's plain success. Without it, gateways keep retrying.

Acknowledging ​

callback.acknowledgement is an Acknowledgement with status, body and headers. Write them with your framework's response API:

FrameworkResponse
node:http, Expressack.send(res): sets the status and headers and ends the response
Fastifyreply.code(ack.status).headers(ack.headers).send(ack.body)
Next.js, Hono, Bun, Denoreturn ack.toResponse(), a Fetch API Response

Acknowledgement.default() is the empty 200 text/plain response most gateways expect.

Gateway callbacks are server-to-server posts: exclude these routes from any CSRF protection your framework applies.

PaymentStatus ​

Every gateway's own status values are mapped onto one string union. The original value stays in callback.gatewayStatus.

ConstantValueMeaning
PaymentStatus.SuccessfulsuccessfulThe customer paid. The only status that means money was collected.
PaymentStatus.PendingpendingStill in progress or waiting on the customer.
PaymentStatus.FailedfailedAttempted and failed or rejected.
PaymentStatus.CanceledcanceledCanceled or closed before completing.
PaymentStatus.ExpiredexpiredThe payment window ran out.
PaymentStatus.UnknownunknownA status this package does not recognize yet. Inspect gatewayStatus.

Statuses are plain strings, so callback.status === 'successful' works. PaymentStatus.isFinal(status) is false for pending and unknown. Unknown statuses never throw.

Each gateway page lists its exact mapping.

Status Checks ​

When a callback is late, ask KBZ Pay, AYA or Yoma directly; Wave Money and CyberSource have no status API.

Checking the status when the callback is lateYour appKBZ PayCallback late or missingAsk for the order statusawait kbz.status(orderId)PaymentStatusResulttrade_status, e.g. PAY_SUCCESSSuccessful? Fulfill oncesame checks as the callbackNot final? Check again laterPaymentStatus.isFinal(result.status)
Checking the status when the callback is late

Status checks return a PaymentStatusResult with the same status, gatewayStatus, gatewayReference and amount fields.

GatewayCall
KBZ Paykbz.status(orderId)
AYA Payment Gatewayaya.status(orderId)
Yoma MMQRyoma.status(payment.reference)
Wave MoneyNo status API: rely on the callback
CyberSourceNo status API: rely on the callback

Every status call is async: await it.

ts
import { ApiError } from '@laranex/myanmar-payments';

let result;
try {
  result = await kbz.status('ORDER_1');
} catch (error) {
  if (error instanceof ApiError) {
    console.error(`KBZ ${error.gatewayCode}: ${error.gatewayMessage}`);
  }
  throw error;
}

if (result.isSuccessful()) {
  // ...
}

See PaymentCallback & Status for every field.

Released under the MIT License, except where a package says otherwise.