Skip to content

Complete payment flow

Configure a merchant backend and payer client from ownership proof through chain-confirmed fulfillment.

This guide builds the default stateless path. Your application owns its order record; the gateway owns references and protocol coordination; the chain and signed receipt provide payment evidence.

PAYMENT PATHOne authorization, five trust boundaries
01Reference

Merchant creates PaymentRef

02Offer

Gateway returns signed 402

03Authorize

Wallet signs EIP-3009

04Settle

Facilitator broadcasts

05Finality

Chain and receipt verify

Funds move directly from payer authorization to payTo. The gateway never holds wallet keys or balances.

Prerequisites

  • A deployed x402 gateway URL such as https://pay.example.com.
  • A merchant-controlled EVM payTo wallet on the configured network.
  • USDC using EIP-3009 on that network.
  • A payer wallet adapter that can sign EIP-712 typed data.
  • Backend secret storage for the merchant API key and notify HMAC key.

Install the single package in both applications and only import the entry point needed by that runtime:

Shell
npm install x402-paysdk

1. Prove payTo ownership

Registration is a two-request challenge flow. The wallet signs a challenge bound to the gateway origin, action, payTo, network, nonce, and expiry.

TypeScript
import { createMerchantChallenge, merchantChallengeTypedData, registerMerchant} from 'x402-paysdk/merchant';const gateway = 'https://pay.example.com';const payTo = '0x1111111111111111111111111111111111111111';const webhookUrl = 'https://merchant.example/x402/notify';const challenge = await createMerchantChallenge(gateway, { payTo, network: 'base', action: 'merchant.register', webhookUrl});const signature = await merchantWallet.signTypedData( merchantChallengeTypedData(challenge.message));const credentials = await registerMerchant(gateway, { challengeId: challenge.challengeId, payTo, network: 'base', label: 'Example Store', webhookUrl, signature});

Store apiKey, notifyHmacKey, and notifyHmacKeyId immediately in a secret manager. The gateway only returns raw credentials at issuance or rotation.

2. Create the merchant client

TypeScript
import { MerchantClient } from 'x402-paysdk/merchant';const merchant = new MerchantClient({ gateway, apiKey: process.env.X402_API_KEY!, notifyHmacKey: process.env.X402_NOTIFY_KEY!, notifyHmacKeyId: process.env.X402_NOTIFY_KEY_ID!, payTo, network: 'base'});

Create this client only on the backend. Do not serialize it, expose it through frontend state, or log its options.

3. Create and persist a PaymentRef

Amounts are atomic USDC units, so 24000000 means 24 USDC for a six-decimal token.

TypeScript
import { createPaymentRef } from 'x402-paysdk/merchant';const ref = await createPaymentRef(merchant, { payTo, network: 'base', asset: 'USDC', amount: '24000000', invoiceId: 'order_7K9M2', label: 'Order 7K9M2', description: 'Example Store checkout', expiresInSecs: 900, notifyUrl: webhookUrl});await orders.savePaymentReference({ orderId: 'order_7K9M2', refId: ref.refId, refUrl: ref.refUrl, expectedAmount: ref.snapshot.amount, expectedPayTo: ref.snapshot.payTo, expiresAt: ref.snapshot.expiresAt});

Return only refUrl and display metadata to the payer application. The same URL can be transported as a link, QR code, deep link, or API payload.

4. Connect the payer wallet

The payer integration supplies a narrow signer. Private key material never enters the SDK.

TypeScript
import type { WalletSigner } from 'x402-paysdk/pay';const wallet: WalletSigner = { address: account.address, signTypedData: (typedData) => walletClient.signTypedData(typedData)};

Before asking for a signature, you can inspect the signed requirements:

TypeScript
import { getPaymentRequired } from 'x402-paysdk/pay';const required = await getPaymentRequired(ref.refUrl);const offer = required.accepts[0];renderConfirmation({ payTo: offer.payTo, amount: offer.amount, network: offer.network, asset: offer.asset});

5. Authorize and settle

Use one unpredictable or business-unique paymentId for a single payment attempt. Reusing it with a different context must fail closed.

TypeScript
import { pay } from 'x402-paysdk/pay';const settlement = await pay({ refUrl: ref.refUrl, paymentId: crypto.randomUUID(), wallet});console.log(settlement.txHash, settlement.receiptStatus);

pay() resolves the HTTPS receive URL, obtains 402 Payment Required, verifies the signed offer and gateway DID identity, builds EIP-3009 typed data, asks the wallet to sign, submits PAYMENT-SIGNATURE, and returns the settlement evidence.

6. Send evidence to the backend

The payer client should send the tx hash and complete signed receipt pair to your backend. Treat them as evidence to verify, not as a trusted “paid” flag.

TypeScript
await fetch('/api/orders/order_7K9M2/payment', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ txHash: settlement.txHash, signedReceipt: settlement.signedPaymentReceipt, receipt: settlement.receipt })});

7. Verify receipt and chain finality

The backend binds verification to the order's stored expectations. Only fulfill after the signature, payment context, transfer, and confirmation policy all pass.

TypeScript
import { verifyReceipt } from 'x402-paysdk/merchant';const verification = await verifyReceipt(merchant, { signedReceipt, receipt, expectedPayTo: order.expectedPayTo, expectedAmount: order.expectedAmount, minConfirmations: 3});if (!verification.valid || !verification.confirmed) { throw new Error(verification.reason ?? 'payment is not final');}await orders.markPaidOnce(order.id, { txHash: verification.txHash!, payer: verification.payer!, receipt});

The markPaidOnce operation belongs in your database and should be atomic. The default gateway intentionally does not own merchant fulfillment state.

8. Add notify as a fast hint

Read the exact raw request body before JSON parsing and use shared atomic nonce storage in multi-instance production.

TypeScript
import { verifySettlementNotify } from 'x402-paysdk/merchant';const result = await verifySettlementNotify( request.headers, rawBody, { [process.env.X402_NOTIFY_KEY_ID!]: process.env.X402_NOTIFY_KEY! }, sharedNonceStore);if (!result.ok) return new Response('invalid notify', { status: 401 });await paymentQueue.enqueue(result.notify);return new Response(null, { status: 204 });

Notify can trigger verification sooner, but it is never financial finality. The queued worker must still call receipt or tx verification.

Completion criteria

  • The payer saw the exact payTo, amount, network, and asset before signing.
  • The signed offer was trusted through pinned signers or same-origin DID discovery.
  • The backend matched the receipt to its stored order expectations.
  • The chain transfer reached the configured confirmation policy.
  • Fulfillment used a single atomic state transition.
  • Credentials, payment signatures, and raw notify bodies were not logged.

Continue with production operations for recovery and reconciliation.