This reference shows the payment-critical parts of an Ambient headless subscription client. Use the Headless x402 subscriptions guide for endpoint examples, supported plans, account usage, key management, and current limitations. See Ambient pricing before choosing a client-side spending limit.
Registration creates a pending order and stores only the bootstrap key's prefix and hash. It does not create a team, usable API-key row, or subscription policy. Ambient creates those together when a valid settled grant completes. A renewal order uses the same payment state but has no bootstrap key.
Ambient's 12-month limit counts nonexpired current and future policies, unexpired pending-order months, and the requested months. Do not create speculative renewal intents: each live intent reserves capacity.
Tested versions#
Use Node.js 20.18 or newer and pin the payment stack. viem and @solana/kit
are signer-adapter dependencies; omit a rail's unused wallet package from a
single-rail client.
npm install --save-exact \
@x402/[email protected] \
@x402/[email protected] \
@x402/[email protected] \
[email protected] \
@solana/[email protected]Do not use an automatic paid-fetch wrapper for a subscription payment URL. The client must durably save one exact authorization before transmitting it and must not let generic HTTP retry middleware repeat the paid request.
Durable state and injected boundaries#
The types below work for both registration and renewal. Construct
PaymentOrder.bootstrap only from a registration response. Validate a
registration response's completion_url is
/billing/x402/subscription-complete, but always submit grants to the fixed
HTTPS endpoint used below. Renewal requests accept the canonical planId field
and the compatibility alias plan_id.
import { x402Client } from "@x402/core/client";
import {
decodePaymentRequiredHeader,
decodePaymentResponseHeader,
encodePaymentSignatureHeader,
} from "@x402/core/http";
import type {
PaymentPayload,
PaymentRequired,
PaymentRequirements,
SettleResponse,
} from "@x402/core/types";
import { ExactEvmScheme, type ClientEvmSigner } from "@x402/evm";
import { ExactSvmScheme, type ClientSvmSigner } from "@x402/svm";
const API_ORIGIN = "https://api.ambient.xyz";
const COMPLETION_URL = `${API_ORIGIN}/billing/x402/subscription-complete`;
const SOLANA = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp";
const SOLANA_USDC = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
const SOLANA_RECIPIENT = "AYYo37bgztqAgP6wp2S9SiYWcijr8gz2o14c8RGWojJZ";
const BASE = "eip155:8453";
const BASE_USDC = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913";
const BASE_RECIPIENT = "0x6992c688c56be442efe1e1ce7d5c95212ed7d59b";
type Network = typeof SOLANA | typeof BASE;
type TrustedOffer = PaymentRequirements & { network: Network };
type PlanId = "starter" | "basic" | "power" | "pro";
type Stage =
| "registered"
| "approved"
| "authorized"
| "attempted"
| "granted"
| "completed"
| "ambiguous";
type PaymentOrder = {
kind: "registration" | "renewal";
orderId: string;
planId: PlanId;
months: number;
paymentUrl: string;
bootstrap?: {
apiKey: string;
orderExpiresAt: string;
};
};
type Approval = {
orderId: string;
planId: PlanId;
months: number;
resourceUrl: string;
scheme: "exact";
network: Network;
asset: string;
recipient: string;
amountMicroUsdc: string;
expectedPayer: string;
approvedAt: string;
};
type PaidResponse = {
status: number;
headers: Record<string, string>;
paymentResponse?: string;
grantToken?: string;
grantSignature?: string;
body?: string;
};
type Completion = {
team_id: string;
plan_id: PlanId;
months: number;
current_period_end: string;
already_completed: boolean;
};
type OrderState = {
stage: Stage;
order: PaymentOrder;
paymentRequiredHeader?: string;
paymentRequired?: PaymentRequired;
selectedOffer?: PaymentRequirements;
approval?: Approval;
paymentPayload?: PaymentPayload;
paymentSignature?: string;
transmissionAttemptedAt?: string;
paidResponse?: PaidResponse;
settlementReceipt?: SettleResponse;
completion?: Completion;
ambiguityReason?: string;
};
type StoredState = { revision: number; value: OrderState };
interface DurableOrderStore {
// This lock must exclude writers in every process, not only this Node process.
withExclusiveLock<T>(orderId: string, fn: () => Promise<T>): Promise<T>;
read(orderId: string): Promise<StoredState>;
// Atomically commits and returns the new revision, or throws on a mismatch.
compareAndSwap(
orderId: string,
expectedRevision: number,
next: OrderState,
): Promise<StoredState>;
}
type WalletChoice =
| { network: typeof BASE; signer: ClientEvmSigner }
| { network: typeof SOLANA; signer: ClientSvmSigner };
function assertOrderIdentity(orderId: string, state: OrderState): void {
if (state.order.orderId !== orderId) {
throw new Error("Loaded state belongs to a different order");
}
}compareAndSwap must be durable before it returns. A filesystem implementation
needs an atomic same-directory replacement, file and directory flushes,
owner-only permissions, and a lock identity that survives replacement. A
database implementation should use a row lock or equivalent transaction. Never
log the API key, payment signature, grant, wallet material, or complete stored
response headers.
Preview, validate, and record approval#
The live 402 is authoritative for availability and amount, but it is not a
trusted recipient list. The selected offer must match the allowlist below. Base
addresses compare lowercase; Solana identifiers remain case-sensitive. The Base
checks require EIP-3009, canonical USDC's EIP-712 domain, and a positive
authorization lifetime no longer than the currently trusted 300-second maximum.
type Preview = {
paymentRequiredHeader: string;
paymentRequired: PaymentRequired;
selectedOffer: TrustedOffer;
};
function positiveAmount(value: unknown): bigint {
if (typeof value !== "string" || !/^[1-9][0-9]*$/.test(value)) {
throw new Error("Amount must be a canonical positive decimal integer");
}
return BigInt(value);
}
function formatMicroUsdc(amount: bigint): string {
const whole = amount / 1_000_000n;
const fraction = (amount % 1_000_000n)
.toString()
.padStart(6, "0")
.replace(/0+$/, "");
return fraction ? `${whole}.${fraction}` : whole.toString();
}
function canonicalIdentifier(network: Network, value: string): string {
const trimmed = value.trim();
if (!trimmed) throw new Error("Identifier is empty");
return network === BASE ? trimmed.toLowerCase() : trimmed;
}
function isTrustedOffer(offer: PaymentRequirements): offer is TrustedOffer {
if (offer.scheme !== "exact") return false;
if (offer.network === SOLANA) {
return offer.asset === SOLANA_USDC && offer.payTo === SOLANA_RECIPIENT;
}
if (offer.network === BASE) {
return (
offer.asset.toLowerCase() === BASE_USDC &&
offer.payTo.toLowerCase() === BASE_RECIPIENT &&
offer.extra.assetTransferMethod === "eip3009" &&
offer.extra.name === "USD Coin" &&
offer.extra.version === "2" &&
Number.isInteger(offer.maxTimeoutSeconds) &&
offer.maxTimeoutSeconds > 0 &&
offer.maxTimeoutSeconds <= 300
);
}
return false;
}
function selectTrustedOffer(
required: PaymentRequired,
paymentUrl: string,
preferredNetwork: Network,
): TrustedOffer {
if (required.x402Version !== 2) throw new Error("Expected x402 v2");
if (required.resource?.url !== paymentUrl) {
throw new Error("Challenge resource differs from the saved payment URL");
}
const amounts = new Set(
required.accepts.map((offer) => positiveAmount(offer.amount).toString()),
);
if (amounts.size !== 1) throw new Error("Offered rails disagree on amount");
const offer = required.accepts
.filter(isTrustedOffer)
.find((candidate) => candidate.network === preferredNetwork);
if (!offer) throw new Error("No trusted offer for the selected network");
return offer;
}
async function previewPayment(
paymentUrl: string,
preferredNetwork: Network,
): Promise<Preview> {
const response = await fetch(paymentUrl, {
method: "POST",
redirect: "manual",
});
if (response.status !== 402) {
throw new Error(`Expected unsigned 402, received ${response.status}`);
}
const header = response.headers.get("Payment-Required");
if (!header) throw new Error("Payment-Required header is missing");
const paymentRequired = decodePaymentRequiredHeader(header);
const selectedOffer = selectTrustedOffer(
paymentRequired,
paymentUrl,
preferredNetwork,
);
return {
paymentRequiredHeader: header,
paymentRequired,
selectedOffer,
};
}
async function saveApproval(
store: DurableOrderStore,
orderId: string,
preview: Preview,
wallet: WalletChoice,
maximumMicroUsdc: bigint,
): Promise<OrderState> {
return store.withExclusiveLock(orderId, async () => {
const stored = await store.read(orderId);
const state = stored.value;
assertOrderIdentity(orderId, state);
if (state.paymentSignature || state.transmissionAttemptedAt) {
throw new Error("An authorization already exists for this order");
}
if (state.stage !== "registered" && state.stage !== "approved") {
throw new Error(`Cannot approve an order in stage ${state.stage}`);
}
const offer = selectTrustedOffer(
preview.paymentRequired,
state.order.paymentUrl,
preview.selectedOffer.network,
);
const amount = positiveAmount(offer.amount);
if (amount > maximumMicroUsdc) {
throw new Error(`Charge ${formatMicroUsdc(amount)} USDC exceeds approval`);
}
if (wallet.network !== offer.network) {
throw new Error("Wallet network differs from the selected offer");
}
const approval: Approval = {
orderId: state.order.orderId,
planId: state.order.planId,
months: state.order.months,
resourceUrl: state.order.paymentUrl,
scheme: "exact",
network: offer.network,
asset: canonicalIdentifier(offer.network, offer.asset),
recipient: canonicalIdentifier(offer.network, offer.payTo),
amountMicroUsdc: amount.toString(),
expectedPayer: canonicalIdentifier(
offer.network,
wallet.signer.address,
),
approvedAt: new Date().toISOString(),
};
const committed = await store.compareAndSwap(orderId, stored.revision, {
...state,
stage: "approved",
paymentRequiredHeader: preview.paymentRequiredHeader,
paymentRequired: preview.paymentRequired,
selectedOffer: offer,
approval,
});
return committed.value;
});
}Show the user or calling policy the exact order, plan, months,
wallet.signer.address, rail, asset, recipient, and formatMicroUsdc(amount)
before calling saveApproval. The maximum is an independent client policy, not a
hardcoded plan-price table.
A payment URL may remain usable for roughly 24 hours, but an authorization does not. Base authorizations currently use a short expiry window (typically 300 seconds), while Solana validity also depends on a recent blockhash. Create the authorization immediately before transmission.
Create and save one authorization#
The lock is acquired and current state is reread before the signer runs. If an
authorization already exists, the function returns it without signing again. The
official 2.11.0 client awaits onAfterPaymentCreation, so the payload and exact
encoded header are durable before createPaymentPayload returns.
function offerMatchesApproval(
offer: PaymentRequirements,
approval: Approval,
): boolean {
if (!isTrustedOffer(offer) || offer.network !== approval.network) return false;
return (
offer.scheme === approval.scheme &&
canonicalIdentifier(approval.network, offer.asset) === approval.asset &&
canonicalIdentifier(approval.network, offer.payTo) === approval.recipient &&
positiveAmount(offer.amount).toString() === approval.amountMicroUsdc
);
}
async function createSavedAuthorization(
store: DurableOrderStore,
orderId: string,
wallet: WalletChoice,
): Promise<OrderState> {
return store.withExclusiveLock(orderId, async () => {
const stored = await store.read(orderId);
const state = stored.value;
assertOrderIdentity(orderId, state);
if (state.paymentSignature) return state;
if (
state.stage !== "approved" ||
!state.paymentRequired ||
!state.selectedOffer ||
!state.approval
) {
throw new Error("Order does not have a durable approved offer");
}
const approval = state.approval;
const paymentRequired = state.paymentRequired;
if (
approval.orderId !== state.order.orderId ||
approval.planId !== state.order.planId ||
approval.months !== state.order.months ||
approval.resourceUrl !== state.order.paymentUrl
) {
throw new Error("Approved terms differ from the saved order");
}
if (wallet.network !== approval.network) {
throw new Error("Wallet network differs from the approved network");
}
if (
canonicalIdentifier(wallet.network, wallet.signer.address) !==
approval.expectedPayer
) {
throw new Error("Wallet payer differs from the approved payer");
}
const client = new x402Client((_version, offers) => {
const selected = offers.find((offer) =>
offerMatchesApproval(offer, approval),
);
if (!selected) throw new Error("Approved offer is no longer selectable");
return selected;
});
if (wallet.network === BASE) {
client.register(BASE, new ExactEvmScheme(wallet.signer));
} else {
client.register(SOLANA, new ExactSvmScheme(wallet.signer));
}
let saved: OrderState | undefined;
client.onAfterPaymentCreation(async ({ paymentPayload }) => {
const committed = await store.compareAndSwap(orderId, stored.revision, {
...state,
stage: "authorized",
paymentPayload,
paymentSignature: encodePaymentSignatureHeader(paymentPayload),
});
saved = committed.value;
});
await client.createPaymentPayload(paymentRequired);
if (!saved?.paymentSignature) {
throw new Error("Authorization was not durably saved");
}
return saved;
});
}If an unattempted saved authorization expires, prove from durable state that
transmission was never marked attempted before clearing it under the same lock,
then preview and approve again. Never clear or replace an authorization after
transmissionAttemptedAt exists or the outcome is ambiguous.
Transmit once, persist first, then validate#
The attempted marker is committed before the network write. Once fetch
resolves, status, all headers, the raw receipt, and both grant headers are
committed before reading the body. The body is diagnostic only; it is not proof
of settlement.
function validateReceipt(state: OrderState): SettleResponse {
const raw = state.paidResponse?.paymentResponse;
const approval = state.approval;
if (!raw || !approval) throw new Error("Settlement receipt is missing");
const receipt = decodePaymentResponseHeader(raw);
if (receipt.success !== true) throw new Error("Settlement did not succeed");
if (typeof receipt.network !== "string" || receipt.network !== approval.network) {
throw new Error("Settlement network differs from approval");
}
if (typeof receipt.payer !== "string" || !receipt.payer.trim()) {
throw new Error("Settlement payer is missing");
}
if (typeof receipt.transaction !== "string" || !receipt.transaction.trim()) {
throw new Error("Settlement transaction is missing");
}
if (
canonicalIdentifier(approval.network, receipt.payer) !==
approval.expectedPayer
) {
throw new Error("Settlement payer differs from approval");
}
if (
receipt.amount !== undefined &&
positiveAmount(receipt.amount).toString() !== approval.amountMicroUsdc
) {
throw new Error("Settlement amount differs from approval");
}
return receipt;
}
function validateSavedGrant(state: OrderState): SettleResponse {
const receipt = validateReceipt(state);
if (
state.paidResponse?.status !== 200 ||
!state.paidResponse.grantToken ||
!state.paidResponse.grantSignature
) {
throw new Error("Successful receipt or Ambient grant is incomplete");
}
return receipt;
}
async function recoverSavedGrant(
store: DurableOrderStore,
orderId: string,
): Promise<OrderState> {
return store.withExclusiveLock(orderId, async () => {
const stored = await store.read(orderId);
const state = stored.value;
assertOrderIdentity(orderId, state);
if (state.stage === "granted" || state.stage === "completed") return state;
if (state.stage !== "attempted" || !state.paidResponse) {
throw new Error("Order has no recoverable saved paid response");
}
const receipt = validateSavedGrant(state);
const committed = await store.compareAndSwap(orderId, stored.revision, {
...state,
stage: "granted",
settlementReceipt: receipt,
});
return committed.value;
});
}
async function transmitSavedAuthorization(
store: DurableOrderStore,
orderId: string,
): Promise<OrderState> {
return store.withExclusiveLock(orderId, async () => {
let stored = await store.read(orderId);
let state = stored.value;
assertOrderIdentity(orderId, state);
if (state.stage === "granted" || state.stage === "completed") return state;
if (state.stage !== "authorized" || !state.paymentSignature) {
throw new Error(`Refusing transmission from stage ${state.stage}`);
}
const paymentSignature = state.paymentSignature;
stored = await store.compareAndSwap(orderId, stored.revision, {
...state,
stage: "attempted",
transmissionAttemptedAt: new Date().toISOString(),
});
state = stored.value;
let response: Response;
try {
response = await fetch(state.order.paymentUrl, {
method: "POST",
redirect: "manual",
headers: { "Payment-Signature": paymentSignature },
signal: AbortSignal.timeout(125_000),
});
} catch {
const ambiguous = await store.compareAndSwap(orderId, stored.revision, {
...state,
stage: "ambiguous",
ambiguityReason: "Paid request ended without an HTTP response",
});
return ambiguous.value;
}
const paymentResponse =
response.headers.get("Payment-Response") ??
response.headers.get("X-Payment-Response") ??
undefined;
const snapshot: PaidResponse = {
status: response.status,
headers: Object.fromEntries(response.headers.entries()),
paymentResponse,
grantToken: response.headers.get("X-Ambient-X402-Grant") ?? undefined,
grantSignature:
response.headers.get("X-Ambient-X402-Grant-Signature") ?? undefined,
};
stored = await store.compareAndSwap(orderId, stored.revision, {
...state,
paidResponse: snapshot,
});
state = stored.value;
try {
const body = await response.text();
stored = await store.compareAndSwap(orderId, stored.revision, {
...state,
paidResponse: { ...snapshot, body },
});
state = stored.value;
} catch {
// The receipt and grants are already durable; the body is non-authoritative.
}
try {
const receipt = validateSavedGrant(state);
const granted = await store.compareAndSwap(orderId, stored.revision, {
...state,
stage: "granted",
settlementReceipt: receipt,
});
return granted.value;
} catch (error) {
const ambiguous = await store.compareAndSwap(orderId, stored.revision, {
...state,
stage: "ambiguous",
ambiguityReason:
error instanceof Error ? error.message : "Invalid paid response",
});
return ambiguous.value;
}
});
}Jumpgate performs bounded, byte-identical facilitator retries internally:
currently at most three facilitator attempts within the 100-second settlement
budget, with 250 ms and one-second backoffs. After settlement, its Ambient
callback currently makes at most three three-second attempts with the same
backoffs. The client still sends the saved paid request once and always calls
completion when it has a grant. Treat 409,
X-Ambient-X402-Error-Code: AMBIGUOUS_X402_SETTLEMENT, or any non-success after
authorization transmission as nonretryable payment state; do not create a
replacement authorization or follow generic Retry-After behavior. The example
intentionally leaves every such result in its terminal local ambiguous state
even when Jumpgate provides a narrower operational classification.
The bounded Jumpgate non-success classifications are:
phase | reason | payment_status |
|---|---|---|
settlement | facilitator_rejected | facilitator_reported_failure |
settlement | settlement_unavailable | not_submitted |
settlement | settlement_unconfirmed | unknown |
receipt_validation | missing_receipt, missing_network, missing_payer, missing_transaction, or unsupported_network | facilitator_reported_success |
post_settlement | post_settlement_failure | facilitator_reported_success |
For facilitator_rejected, the optional facilitator_reason is one of
insufficient_funds, authorization_not_yet_valid, authorization_expired,
invalid_authorization, payment_mismatch, transaction_rejected,
unsupported, or invalid_response. It represents a recognized pre-broadcast
validation rejection with no settlement identifier. A missing, unrecognized, or
conflicting reason, or a failure carrying a transaction or legacy signature, is
instead settlement_unconfirmed with payment_status=unknown. Bounded
rejection fields are safe to display but are not independent on-chain proof.
settlement_unavailable means Jumpgate proved a local failure happened before
facilitator submission.
The shared AMBIGUOUS_X402_SETTLEMENT code is a compatibility envelope: every
variant stops automatic retries, but only payment_status=unknown or
payment_status=facilitator_reported_success indicates funds may be at risk.
Neither a recognized rejection nor confirmed non-submission permits automatic
replacement; the client must preserve its saved authorization and stop payment
handling.
The operator pager is intentionally narrower than client handling: it fires only for an unknown settlement outcome, a reported success that cannot produce a usable grant, or an Ambient callback that is rejected or exhausts its retries. A downstream client disconnect does not page by itself or cancel an already-started callback, and a successful callback provisions the account even if the client never consumes the response.
Intermediaries such as Cloudflare can replace Jumpgate's structured body and headers. The conservative rule applies even when those fields are absent.
If a process restarts after saving a valid receipt and both grant headers but
before changing the stage to granted, call recoverSavedGrant. It validates
and promotes only the already-saved response and performs no network request.
Complete with the same grant#
Completion is public and idempotent. Jumpgate may already have completed the
order through its callback, so a client's first completion can legitimately
return either already_completed: false or true. Registration or renewal can
return 403 while sales are paused; already-issued valid grants remain
completable.
The helper below retries only the exact same signed grant on transport errors,
408, 429, and 5xx. It never follows redirects. 400, 409, and other
nonretryable responses stop for reconciliation and must not trigger another
payment.
const sleep = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
function parseCompletion(value: unknown, order: PaymentOrder): Completion {
if (!value || typeof value !== "object") {
throw new Error("Completion response is not an object");
}
const body = value as Record<string, unknown>;
if (typeof body.team_id !== "string" || !body.team_id.trim()) {
throw new Error("Completion team_id is missing");
}
if (body.plan_id !== order.planId || body.months !== order.months) {
throw new Error("Completion does not match the saved order");
}
if (
typeof body.current_period_end !== "string" ||
Number.isNaN(Date.parse(body.current_period_end))
) {
throw new Error("Completion current_period_end is invalid");
}
if (typeof body.already_completed !== "boolean") {
throw new Error("Completion already_completed is invalid");
}
return body as Completion;
}
function retryableCompletionStatus(status: number): boolean {
return status === 408 || status === 429 || status >= 500;
}
async function completeProvisioning(
store: DurableOrderStore,
orderId: string,
): Promise<OrderState> {
return store.withExclusiveLock(orderId, async () => {
let stored = await store.read(orderId);
const state = stored.value;
assertOrderIdentity(orderId, state);
if (state.stage === "completed") return state;
if (
state.stage !== "granted" ||
!state.paidResponse?.grantToken ||
!state.paidResponse.grantSignature
) {
throw new Error("Order does not have a saved grant");
}
const requestBody = JSON.stringify({
grantToken: state.paidResponse.grantToken,
signature: state.paidResponse.grantSignature,
});
const backoffs = [0, 250, 1_000] as const;
for (let attempt = 0; attempt < backoffs.length; attempt += 1) {
if (backoffs[attempt]) await sleep(backoffs[attempt]);
let response: Response;
try {
response = await fetch(COMPLETION_URL, {
method: "POST",
redirect: "manual",
headers: { "Content-Type": "application/json" },
body: requestBody,
signal: AbortSignal.timeout(10_000),
});
} catch {
if (attempt + 1 < backoffs.length) continue;
throw new Error("Completion not confirmed; retry this same grant later");
}
if (retryableCompletionStatus(response.status)) {
if (attempt + 1 < backoffs.length) continue;
throw new Error("Completion not confirmed; retry this same grant later");
}
if (!response.ok) {
throw new Error(
`Completion rejected with HTTP ${response.status}; reconcile without paying again`,
);
}
let completion: Completion;
try {
completion = parseCompletion(await response.json(), state.order);
} catch {
if (attempt + 1 < backoffs.length) continue;
throw new Error("Completion response invalid; retry this same grant later");
}
stored = await store.compareAndSwap(orderId, stored.revision, {
...state,
stage: "completed",
completion,
});
return stored.value;
}
throw new Error("Unreachable completion state");
});
}Restart matrix#
Always reread the order under its exclusive lock.
| Durable stage | Safe action |
|---|---|
registered | Preview the unsigned challenge and record exact approval. No authorization exists. |
approved | Create and durably save one authorization immediately before sending. |
authorized | Send the exact saved authorization only if its validity can still be proven. If expired and definitely unattempted, clear it under lock and reapprove. |
attempted | Never resend. If a valid receipt and both grants are saved, call recoverSavedGrant; otherwise reconcile. |
granted | Retry completion using the exact saved grant. This cannot add another month. |
completed | Use the API key; do not pay again. |
ambiguous | Stop payment automation. Probe initial registration activation once or contact support; never create a replacement authorization. |
For an initial registration, the saved bootstrap key returning an authenticated
expected subscription from GET https://api.ambient.xyz/billing/usage-summary
can prove the callback activated it. A 401 does not prove the payment failed.
The same probe cannot attribute an ambiguous renewal because the account was
already active.
When reconciliation is necessary, email
[email protected] with only the order ID, rail,
payer, and transaction or EVM authorization nonce. Never send the API key,
Payment-Signature, grant, private key, or seed phrase.
Integration checklist#
- Persist the complete registration response before payment; the API key is shown once and is inactive until completion.
- Model renewal without registration-only key, expiry, or completion URL fields.
- Validate the exact resource URL, x402 v2,
exactscheme, allowlisted asset and recipient, equal offered amounts, and Base EIP-3009 domain and timeout bound. - Persist exact approval, expected payer, authorization, and attempted marker in that order.
- Persist paid status, headers, raw receipt, and grants before reading or interpreting the body.
- Require a successful typed receipt, expected payer/network, optional amount match, and both grant headers.
- Send the paid request once; retry only completion with the same signed grant.
- Redact every credential and payment artifact from logs and metrics.
- Use mocks for routine tests. Any real payment requires separate approval and an explicit maximum spend.
Related pages#
- Headless x402 subscriptions: endpoints, plans, key management, and limitations
- Pay per request with x402: the single-request payment flow