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: { 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.

On this page