GitHub

Cookbook

Accept a payment link

Issue a signed payment request, let any conforming wallet read and pay it, and close the loop with a verifiable receipt.

The whole flow, from a merchant's order to a receipt a third party can check.

1. Issue the request

export async function createInvoice(order: Order) {
  const request = await path.createRequest(
    {
      amount: order.total.toFixed(0),
      currency: 'XOF',
      accepts: [
        { rail: 'mobile_money', currency: 'XOF' },
        { asset: 'USDC', chain: 'base' },
      ],
      feeDisclosure: {
        network_fee: '0',
        operator_fee: order.fee.toFixed(0),
        currency: 'XOF',
        total_deducted: order.fee.toFixed(0),
      },
      orderReference: order.id,
      expiresAt: new Date(Date.now() + 24 * 3600_000).toISOString(),
    },
    order.id, // idempotency
  );

  await orders.update(order.id, { pathReference: request.reference, payUrl: request.url });
  return request;
}

Two things worth doing here rather than later.

Idempotency on your own order id. On the retry after a timeout — when you do not know whether the first call landed — a replay returns the original request instead of creating a second one, and your customer does not receive two invoices.

Fee disclosure inside the request. Passed here, the fee travels inside the signature and cannot differ from what the payer was shown. A price disclosed after the decision is not a disclosure.

2. Show it

<a href={order.payUrl}>Pay {formatXOF(order.total)}</a>
<QRCode value={order.payUrl} />

The URL works as a link and as a QR code. A wallet handles either.

3. The payer's side — any wallet, no credential

import { PathClient, verifyAgainstIssuer, PathApiError } from '@pathprotocol/sdk';

export async function openPaymentLink(url: string) {
  const issuerBase = new URL(url).origin;
  const wallet = new PathClient({ baseUrl: issuerBase });

  let request;
  try {
    request = await wallet.readRequest(url);
  } catch (err) {
    if (err instanceof PathApiError) {
      switch (err.code) {
        case 'path.request.expired':
          return { screen: 'expired', message: 'This request has expired. Ask for a new link.' };
        case 'path.request.revoked':
          return { screen: 'revoked', message: 'The merchant withdrew this request.' };
        case 'path.core.not_found':
          return { screen: 'not-found', message: 'We could not find this request.' };
      }
    }
    throw err;
  }

  const { valid, reason } = await verifyAgainstIssuer(request, issuerBase);
  if (!valid) return { screen: 'untrusted', message: reason };

  return {
    screen: 'confirm',
    issuer: request.issuer,   // show this, verified
    amount: request.amount,
    currency: request.currency,
    fees: request.fees,
    accepts: request.accepts,
  };
}

Show request.issuer, never the URL. A payer confronted with https://some-provider.xyz/p/abc closes the app — the same reason a card terminal shows a merchant name rather than an acquirer's hostname. The domain says where it was fetched from; the signature says who is responsible.

Note the three distinct failures. A payer told "not found" when a link merely expired will retype it, blame themselves, and call support.

4. Close the loop

export async function onPaymentSettled(order: Order, rail: RailResult) {
  await path.markPaid(order.pathReference);

  const receipt = await path.issueReceipt({
    requestId: order.pathReference,
    payerMember: rail.payerMember,
    amount: order.total.toFixed(0),
    currency: 'XOF',
    rail: rail.name,
    sourceReference: rail.reference,
    sourceTxHash: rail.txHash,
  });

  await orders.update(order.id, { receiptReference: receipt.reference });
  return receipt;
}

Marking paid and issuing a receipt are two calls because they are two events, and on some rails they are hours apart.

5. Give the receipt away

const receiptUrl = `${process.env.PATH_API_URL}/api/path/v1/receipts/${receipt.reference}`;

Public. The customer's accountant, their bank, a customs officer — anyone can verify it without an account anywhere.

What went right

The payer's wallet needed no relationship with you. It read a public link, verified a signature against your published keys, and paid. No integration, no onboarding, no bilateral agreement.

That is the pillar doing its job: a checkout that only works inside the wallet your provider chose is not a checkout, it is a funnel.

On this page