Skip to main content

Payment Flows

Flow selection matrix

  • Use push when payer can submit transactions directly.
  • Use permit2/eip2612/eip3009 when payer signs and a relayer submits.
  • Use session-enhanced when an agent needs bounded spend permissions.
  • Use relay-assisted when centralized submission/bundling is preferred.

Shared setup

What this does: defines common values used by the flow snippets below.

import { createApiClient, createFlexIntent, RpcTransport, sendRouterPayment } from '@pepay/x402flex';
import { ethers } from 'ethers';

const MERCHANT_ADDRESS = '0x1111111111111111111111111111111111111111';
const ROUTER_ADDRESS = '0xf14f56A54E0540768b7bC9877BDa7a3FB9e66E91';
const TOKEN_ADDRESS = '0x55d398326f99059fF775485246999027B3197955';
const REFERENCE_ID = 'order-2001';

const provider = new ethers.JsonRpcProvider(process.env.BNB_RPC_URL);
const signer = new ethers.Wallet(process.env.PAYER_PRIVATE_KEY!, provider);

const api = createApiClient({
baseUrl: 'https://api.bnbpay.org',
apiKey: process.env.BNBPAY_RELAY_KEY,
});

const intentBundle = createFlexIntent({
merchant: MERCHANT_ADDRESS,
token: TOKEN_ADDRESS,
amount: ethers.parseUnits('25', 18),
chainId: 97,
referenceId: REFERENCE_ID,
scheme: 'exact:evm:permit2',
});

Expected result: reusable intentBundle, signer, and API client context.

Push flow

What this does: payer submits router payment directly (native or token).

const pushIntent = createFlexIntent({
merchant: MERCHANT_ADDRESS,
token: ethers.ZeroAddress,
amount: ethers.parseEther('0.1'),
chainId: 97,
referenceId: 'order-2001-push',
scheme: 'push:evm:direct',
});

await sendRouterPayment({
transport: new RpcTransport(signer),
routerAddress: ROUTER_ADDRESS,
intent: pushIntent.intent,
witness: pushIntent.witness,
reference: pushIntent.referenceId,
});

Expected result: transaction settles and emits PaymentSettledV2.

Common failure modes:

  • insufficient native balance for gas/value
  • wrong router for selected network

Permit2 flow

What this does: payer signs Permit2 + witness and relay submits payWithPermit2.

const permit2Payload = {
permit: {
permitted: {
token: TOKEN_ADDRESS,
amount: intentBundle.intent.amount.toString(),
},
nonce: 1,
deadline: Math.floor(Date.now() / 1000) + 3600,
},
transferDetails: {
to: ROUTER_ADDRESS,
requestedAmount: intentBundle.intent.amount.toString(),
},
signature: '0xPERMIT2_SIGNATURE',
};

await api.relay.payment({
network: 'bnbTestnet',
scheme: 'permit2',
intent: {
...intentBundle.intent,
amount: intentBundle.intent.amount.toString(),
},
witness: intentBundle.witness,
witnessSignature: '0xWITNESS_SIGNATURE',
permit2: permit2Payload,
reference: intentBundle.referenceId,
});

Expected result: relay returns { txHash, paymentId }.

Common failure modes:

  • transferDetails.to is not router address
  • permit amount does not match intent amount

EIP-2612 flow

What this does: payer signs EIP-2612 permit + witness and relay submits payWithEIP2612.

await api.relay.payment({
network: 'bnbTestnet',
scheme: 'eip2612',
intent: {
...intentBundle.intent,
amount: intentBundle.intent.amount.toString(),
},
witness: intentBundle.witness,
witnessSignature: '0xWITNESS_SIGNATURE',
eip2612: {
deadline: Math.floor(Date.now() / 1000) + 3600,
v: 27,
r: '0x' + '11'.repeat(32),
s: '0x' + '22'.repeat(32),
},
reference: intentBundle.referenceId,
});

Expected result: pull payment settles with EIP-2612 authorization.

EIP-3009 flow

What this does: payer signs ReceiveWithAuthorization using intent-derived nonce.

import { deriveEip3009Nonce, hashPaymentIntent } from '@pepay/x402flex';

const intentHash = hashPaymentIntent(intentBundle.intent);
const authNonce = deriveEip3009Nonce({
intentHash,
router: ROUTER_ADDRESS,
chainId: 97,
});

await api.relay.payment({
network: 'bnbTestnet',
scheme: 'eip3009',
intent: {
...intentBundle.intent,
amount: intentBundle.intent.amount.toString(),
},
witness: intentBundle.witness,
witnessSignature: '0xWITNESS_SIGNATURE',
eip3009: {
validAfter: 0,
validBefore: Math.floor(Date.now() / 1000) + 3600,
authNonce,
v: 27,
r: '0x' + '33'.repeat(32),
s: '0x' + '44'.repeat(32),
},
reference: intentBundle.referenceId,
});

Expected result: authorization nonce is replay-safe and intent-bound.

Common failure mode: relay rejects non-derived authNonce.

Session-enhanced flow

What this does: binds payment execution to session limits with tagged references.

import { formatSessionReference, buildSessionContext } from '@pepay/x402flex';

const sessionId = '0x' + '55'.repeat(32);
const sessionReference = formatSessionReference(
intentBundle.referenceId,
sessionId,
intentBundle.resourceId,
);

const session = buildSessionContext({ sessionId }, { defaultAgent: signer.address });

await sendRouterPayment({
transport: new RpcTransport(signer),
routerAddress: ROUTER_ADDRESS,
intent: intentBundle.intent,
witness: intentBundle.witness,
reference: sessionReference,
session,
sessionAuth: {
sessionId,
intentHash,
schemeId: intentBundle.schemeId,
spendNonce: 0n,
expiresAt: BigInt(Math.floor(Date.now() / 1000) + 3600),
epoch: 0n,
},
sessionAuthSignature: '0xSESSION_AUTH_SIGNATURE',
});

Expected result: settlement metadata includes session-linked reference data.

Relay-assisted flow

What this does: submits approval + payment via relay bundle route.

await api.relay.permit2Bundle({
network: 'bnbTestnet',
intent: {
...intentBundle.intent,
amount: intentBundle.intent.amount.toString(),
},
witness: intentBundle.witness,
witnessSignature: '0xWITNESS_SIGNATURE',
permit2: permit2Payload,
approvalTx: '0xSIGNED_APPROVAL_TX',
targetBlock: 12345678,
reference: intentBundle.referenceId,
});

Expected result: relay responds with bundle metadata (bundleId, targetBlock).

Verification checklist

  • paymentId exists in API/indexer records.
  • resourceId matches expected invoice/resource.
  • referenceData equals business reference (including session tags if used).
  • scheme and network match your selected route.