Accept: application/json and receives a 402 Payment Required challenge over x402. Both paths converge on the same signature. One URL, two audiences, one PINT underneath.
This guide is for partners in the x402 private alpha. If you have not yet read the Payment Links and x402 concept page, start there — it frames the mental model this quickstart assumes.
01 / PREREQUISITES
Before you start.
Three things have to be in place before the first call lands. Each is a one-time setup step for the owner account.- A Platform API credential. Every owner-facing route (
POST /v0/payment-links, list, detail) requires a user-status gate — the authenticated account must be onboarded to at leastpending. See authentication for the JWT flow. - A signer wallet. The
PurchaseIntentcarried by the link is an EIP-712 payload signed by the owner’s wallet. The signature is the cryptographic anchor — the link’s slug is literally the signature bytes, base64url-encoded. See PINTs for the mint flow, scope catalogue, and the canonical EIP-712 domain. - A payer-side wallet that speaks x402. Any EIP-3009-capable wallet on a supported chain can settle the link. Browser payers land on the hosted pay view; agents negotiate the 402 handshake directly.
02 / CREATE A LINK
Sign once, share forever.
A payment link is created with a single POST. The owner signs the innerPurchaseIntent with their wallet, submits that payload alongside the hex ECDSA signature, and the API returns a PaymentLinkResponse whose slug is deterministically derived from the signature bytes. Re-signing the same intent produces the same slug — this is intentional and guards against duplicate issuance.
Sign the PurchaseIntent payload
The inner payload is an EIP-712
PurchaseIntent struct. See PINTs for the domain separator, type definitions, and a signing walkthrough. The output is a 130-hex-character ECDSA signature (0x + r + s + v).POST to /v0/payment-links
Submit the payload, signature, accepted chains, and an expiry. The API verifies the signature against the payload, mints the underlying PINT, derives the slug, persists the link, and returns the full response with navigation.
Request
SeePOST /v0/payment-links for the full parameter and response schema reference.
Request fields
| Field | Type | Required | Description |
|---|---|---|---|
pint | object | Yes | The EIP-712 PurchaseIntent payload the owner signed. See PINTs for the field catalogue. Inner expires_at is epoch seconds. |
signature | string | Yes | 0x-prefixed hex ECDSA signature. Must match ^0x[0-9a-fA-F]{130}$. |
accepted_chains | array | Yes | Chain IDs the owner will accept settlement on. At least one; see supported chains. |
expires_at | integer | Yes | Link expiry as epoch milliseconds. Must be in the future and within 90 days from now. |
max_uses | integer | No | Settlement cap (≥1). Omit or null for unlimited redemptions until expiry. |
fee_policy | object | No | Opaque JSON passed through to settlement. Shape is not stable — do not depend on specific keys without coordination. |
description | string | No | Human-readable label, max 280 characters. Whitespace is stripped. |
Response — 201 Created
Key response fields
| Field | Description |
|---|---|
slug | URL-safe base64 of the signature bytes (no padding). Validated by ^[A-Za-z0-9_-]{16,128}$. Use as {slug} in subsequent requests. |
pint_uri | Canonical identifier for the underlying PINT. Used as the path segment in _links.pint (URL-encoded). |
status | Lifecycle state: pending, settled, or expired. See section 05. |
amount | Settlement amount as a decimal string in the asset’s smallest unit (mirrors pint.max_amount). |
asset | Symbol, contract address, and decimals of the asset being paid. address is null for fiat-denominated assets. |
pay_to | Wallet address that receives settlement funds (pint.wallet). |
accepted_chains | Chain IDs a payer may settle on. |
usage_count | Number of settlements recorded against the link so far. |
_links.pay | The public pay URL — this is what you share with humans and agents. |
_links.public | The payer-facing JSON view, safe to render without authentication. |
_links.pint | The PINT URI, URL-encoded for direct resolution. |
03 / SHARE AND INSPECT
Three surfaces, one truth.
A payment link speaks through three surfaces: the shareable pay URL, the public payer view, and the owner-scoped inspection endpoints. They are three windows onto the same persisted object — nothing about the link changes based on which surface reads it.The shareable pay URL — /pay/{slug}
See GET /pay/{slug}.
This is the URL you hand to a payer. It is protocol-agnostic: a browser gets an HTML page that meta-refreshes to the public view; an agent sets Accept: application/json and begins the x402 handshake. See section 04 for the agent path.
The public payer view — GET /v0/payment-links/public/{slug}
See GET /v0/payment-links/public/{slug}.
A payer-facing JSON projection safe to render without authentication. It omits what the owner should not share publicly and includes the full signed EIP-712 mandate so a wallet or agent can verify the signature locally before settling.
410 Gone with PAYMENT_LINK_EXPIRED once the effective expiry has passed.
Owner inspection — authenticated
The owner can fetch a single link or list their links with filters. Both endpoints require the same JWT used for creation. Get one link:GET /v0/payment-links/{slug}.
Returns the same PaymentLinkResponse shape as POST. If the slug does not exist — or exists but belongs to another user — the response is 404 PAYMENT_LINK_NOT_FOUND. Ownership is not disclosed.
List with filters:
GET /v0/payment-links.
| Query param | Type | Description |
|---|---|---|
status | string | Filter by lifecycle state: pending, settled, or expired. |
from_date | string | Start of creation-date range. Accepts epoch ms or YYYY-MM-DD. |
to_date | string | End of creation-date range. Same format. Inverted ranges return 400 GENERAL_VALIDATION_ERROR. |
limit | integer | Page size, 1–100. Default 50. |
offset | integer | Page offset, ≥0. Default 0. |
04 / THE x402 HANDSHAKE
Two round trips. One signature.
An agent that resolves/pay/{slug} sets Accept: application/json and receives a 402 Payment Required response carrying an x402 v2 challenge. The agent signs the challenge with its payer wallet, base64-encodes the signed envelope, and retries the same URL with the envelope in the PAYMENT-SIGNATURE header. The server validates the envelope, dispatches to the x402 facilitator, and returns a settlement receipt.
The 402 challenge shape
On the first hit (Accept: application/json, no PAYMENT-SIGNATURE), the response is 402 X402PaymentRequired:
accepts entry is returned per chain in the owner’s accepted_chains, in the order the owner provided. Only the exact scheme is supported today; an envelope carrying a different scheme is rejected with 400 PAYMENT_LINK_X402_UNSUPPORTED_SCHEME.
The retry
The agent constructs an x402 signed-payload envelope, base64-encodes it (standard base64, not base64url), and retries:x402Version, scheme, and network are checked against the link’s accepted set. The inner payload is scheme-specific (EIP-3009 fields for exact on EVM) and is carried opaquely to the facilitator.
A successful retry returns 200 X402SettlementResponse:
409 PAYMENT_LINK_ALREADY_SETTLED regardless of envelope validity — the check sits above the signature branch. The signature does not reopen a closed link.
05 / LIFECYCLE AND EXPIRY
Pending. Settled. Expired.
A payment link has three lifecycle states.settled and expired are terminal.
| State | Meaning | Fields populated |
|---|---|---|
pending | Issued, unsettled, and inside the effective expiry window. | Creation fields only. |
settled | A payer successfully redeemed the link. | settled_at, tx_hash, settle_chain_id, payer_addr, usage_count. |
expired | The effective expiry passed before settlement. Terminal. | Creation fields only. |
payment_link.expires_at and the underlying pint.expires_at. Both are persisted as epoch milliseconds — the API normalises the PINT’s seconds-valued EIP-712 input on the server. Whichever deadline comes first closes the window for both the public view and the agent pay route.
Once a link is settled, further retries return 409 PAYMENT_LINK_ALREADY_SETTLED. The public view continues to render — settled_at, tx_hash, and payer_addr are now populated and serve as a permanent receipt. A settled link is a permanent record, not a deleted one.
06 / ERRORS
RFC 7807, everywhere.
Every error is returned as RFC 7807 Problem Details with a stableerror_code. The ones a partner will encounter most:
| Status | Error code | When you hit it |
|---|---|---|
| 400 | GENERAL_VALIDATION_ERROR | Malformed slug, invalid or inverted date range on list. |
| 400 | PAYMENT_LINK_X402_INVALID_PAYLOAD | PAYMENT-SIGNATURE envelope was not parseable. |
| 400 | PAYMENT_LINK_X402_VERSION_MISMATCH | Envelope x402Version is not 2. |
| 400 | PAYMENT_LINK_X402_UNSUPPORTED_SCHEME | Envelope scheme is not exact. |
| 400 | PAYMENT_LINK_X402_UNSUPPORTED_NETWORK | Envelope network is not in the link’s accepted_chains. |
| 401 | — | Missing or invalid JWT on an owner-scoped route. |
| 403 | — | User-status gate rejected the caller (account not yet at pending). |
| 404 | PAYMENT_LINK_NOT_FOUND | Slug does not exist, or exists but is owned by another user. |
| 409 | PAYMENT_LINK_SLUG_CONFLICT | Slug collision on create — retry the request. |
| 409 | PAYMENT_LINK_ALREADY_SETTLED | Retry against a link whose transaction_id is populated. |
| 410 | PAYMENT_LINK_EXPIRED | Effective expiry has passed. |
| 422 | — | Request body failed schema validation. |
| 424 | — | Upstream PINT issuance failed during link creation. |
| 429 | — | 60-per-minute per-IP rate limit on public endpoints. |
instance field on each error points to the route that emitted it; the trace_id field ties it to your Logfire span for support. One format, one code space — read the code and act.
07 / SEE ALSO
Related.
Payment Links and x402
The concept page — why the URL and the 402 carry the same signature, and what the verifier checks.
Purchase Intents (PINTs)
The signed primitive underneath every payment link. Mint flow, scopes, and EIP-712 shape.
Accept a PINT at checkout
The verifier-side flow — what a merchant does when a payer presents a PINT JWT.
Error handling
Full RFC 7807 reference and the stable error-code catalogue.
Reference
Supported chains
At private-alpha launch, Sei is the settlement network. Base, Optimism, and Tempo follow shortly after. Theaccepted_chains field stays enum-typed across the roster — code against the enum and new networks surface without an API change.
| Chain ID | Network | Alpha status |
|---|---|---|
| 1329 | Sei | Launch chain |
| 1328 | Sei Testnet | Available for integration testing |
| 8453 | Base | Coming soon |
| 10 | Optimism | Coming soon |
| — | Tempo | Coming soon |
Lifecycle statuses
| Value | Terminal? |
|---|---|
pending | No |
settled | Yes |
expired | Yes |
x402 schemes
| Value | Supported today |
|---|---|
exact | Yes |
Questions.
Can the same PurchaseIntent back two links?
Can the same PurchaseIntent back two links?
No. The slug is the signature bytes, url-safe base64-encoded — signing the same intent twice produces the same slug, and the second create call returns
409 PAYMENT_LINK_SLUG_CONFLICT rather than issuing a duplicate. This is intended: one signed intent, one link.What happens if `max_uses` is omitted?
What happens if `max_uses` is omitted?
The link accepts settlements until its effective expiry. Most integrations want
max_uses: 1 — it is the safer default for invoice-style flows.Why do owner and public views return different shapes?
Why do owner and public views return different shapes?
The owner view mirrors the create response — it is the authoritative object, including
pint_uri and usage_count useful for reconciliation. The public view adds the full EIP-712 message, signature, and nonce so a payer wallet can verify locally before settling. Neither view exposes anything that would compromise the link.Which chains will be supported first?
Which chains will be supported first?
Sei at launch. Base, Optimism, and Tempo follow shortly after. The Sei testnet (
1328) is available for integration testing. The accepted_chains field stays enum-typed across the roster — code against the enum and new networks surface without an API change.