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: { value: order.total.toFixed(0), asset_type: 'fiat', asset_code: 'USD' },
accepts: [
{
rail: 'book',
asset_type: 'fiat',
asset_code: 'USD',
fees: [{ kind: 'operator', amount: { value: order.fee.toFixed(0), asset_type: 'fiat', asset_code: 'USD' }, paid_by: 'payer' }],
},
{
rail: 'blockchain',
chain: { slug: 'base' },
asset_type: 'token',
asset_code: 'USDC',
standard: 'erc20',
contract: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913',
},
],
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.
Fees on each accepts entry. They travel 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 {formatUSD(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,
accepts: request.accepts,
};
}Show request.issuer, never the URL. A payer confronted with https://some-provider.xyz/path/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({
issuedBy: 'payee',
request: order.pathReference,
via: { rail: 'book', asset_type: 'fiat', asset_code: 'USD' },
received: { value: order.total.toFixed(0), asset_type: 'fiat', asset_code: 'USD' },
railReferences: [{ type: 'book_entry', value: rail.reference }],
});
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 = `${new URL(process.env.PATH_API_URL!).origin}/path/${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.