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.