Developer docs
Let customers pay with any coin on any chain, and receive the exact price in your stablecoin. Create a checkout with one API request, send the customer to it, and get a signed webhook when you're paid.
No code needed for a simple shop: payment links and QR signs work from the dashboard, and the five-minute setup guide walks you through it. These docs are for integrating with your own site or app.
Quickstart
Four steps, about five minutes:
- Sign in and set up your business: the stablecoin you want to receive and your wallet address.
- In Developers, create an API key, and set your webhook URL.
- Create a checkout from your server, and redirect to its url:
curl https://payanychain.com/api/v1/checkout/sessions \
-H "Authorization: Bearer $PAYANYCHAIN_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042" \
-d '{
"amount": "25.00",
"description": "Order #1042",
"client_reference_id": "1042",
"success_url": "https://yourshop.com/orders/1042/thanks",
"cancel_url": "https://yourshop.com/cart"
}'{
"id": "cs_8Hq2sV0pLx4TnR1mQe7Z",
"object": "checkout.session",
"status": "open",
"amount": "25",
"amount_fee": "0.2",
"amount_net": "24.8",
"currency": { "symbol": "USDC", "chain": "base", "decimals": 6, ... },
"url": "https://payanychain.com/pay/cs_8Hq2sV0pLx4TnR1mQe7Z",
"expires_at": "2026-10-07T13:00:00.000Z",
...
}- When the customer pays, your webhook receives
checkout.session.completed. Verify its signature and fulfil the order. Don't rely on the redirect tosuccess_url: the customer may close the tab first.
Authentication
Send your secret API key as a bearer token on every request. Keys start with sk_ and are shown once, when you create them in the dashboard. You can have several and revoke any of them.
Authorization: Bearer sk_...Keep keys on your server. A missing, wrong or revoked key returns 401 with the code unauthorized.
Checkouts
A checkout session is one payment for one amount. It has a hosted page (url) where the customer picks a coin and pays.
Create a checkout
POST /api/v1/checkout/sessions returns 201 and the session.
- amountstring, required
- The price in your payout stablecoin, as a decimal string, e.g.
"25.00". Must be greater than 0, with no more decimals than the token has (6 for USDC and USDT). - descriptionstring
- Shown to the customer. Up to 500.
- client_reference_idstring
- Your own order or cart id, returned in webhooks. Up to 200.
- metadataobject
- Up to 20 string keys (40 chars) and values (500 chars), returned as is.
- success_urlhttps URL
- Where the customer goes after paying. Nothing is appended to it.
- cancel_urlhttps URL
- Shown as a link back to your site while unpaid or after expiry.
- expires_in_minutesinteger
- 5 to 10080 (7 days). Default 60.
Idempotency. Send an Idempotency-Key header (up to 255 characters) to make retries safe. Repeating a key returns the session it first created, in its current state, even if the body differs. Keys don't expire.
Retrieve and list
GET /api/v1/checkout/sessions/{id}returns a session. Once paid, itspaymentshows what the customer paid with.GET /api/v1/checkout/sessions?limit=20&before=…lists sessions, newest first, as{ "object": "list", "data": [...], "has_more": true }.limitis 1 to 100 (default 20). For the next page, pass the last session'screated_atasbefore.
Session statuses
- open
- Waiting for the customer to pay.
- processing
- A deposit arrived and is being converted and delivered.
- paidfinal
- You've been paid. This wins even if the session had expired.
- expiredfinal
- Nobody paid in time. A session never expires while a payment is still on its way.
The session object
- idstring
- Starts with
cs_. - statusstring
- See above.
- amountstring
- What the customer is charged, tip included. Trailing zeros are dropped ("25").
- amount_base_unitsstring
- The same, as an integer.
- amount_subtotal / amount_tipstring
- Price and tip.
- amount_fee / amount_netstring
- The fee, and what you receive. See Fees and amounts.
- fee_bpsinteger
- The fee rate, e.g. 80 for 0.8%.
- currencyobject
- Your payout token:
symbol,chain,decimals,asset_id. - settlement_addressstring
- Where this payment is paid out, fixed when the session was created.
- urlstring
- The hosted checkout page.
- paymentobject | null
- The settled payment, when retrieving a paid session.
- payment_linkstring | null
- The
pl_link it came from, if any. - description, client_reference_id, metadata, success_url, cancel_url
- As you sent them.
- created_at, expires_at, paid_atISO 8601
paid_atis null until paid.
A payment has an id (pa_), status, origin (the coin and chain the customer paid with), amount_in, deposit_address, refund_address, origin_tx_hashes, destination_tx_hashes and refund_reason.
Payment links
A payment link is a reusable page, like payanychain.com/l/coffee, that anyone can open and pay. Each customer who clicks Pay gets their own checkout session, with the link's id in payment_link. You can manage links in the dashboard or with the API.
POST /api/v1/payment_linkscreates one (201).GET /api/v1/payment_linksandGET /api/v1/payment_links/{id}list and retrieve, with the same paging as checkouts.PATCH /api/v1/payment_links/{id}changes any field exceptslug. Send"active": falseto switch a link off. Changes apply to new customers only.
- titlestring, required
- Up to 120 characters.
- slugstring
- The link's name in the URL: 3 to 48 lowercase letters, numbers and single dashes. Random if left out. Can't be changed.
- descriptionstring
- Up to 500.
- amountstring | null
- A fixed price. Leave it out to let the customer choose the amount.
- min_amount / max_amountstring
- Limits for a customer-chosen amount.
- tips_enabledboolean
- Offer an optional tip. Fixed-price links only.
- tip_percentagesinteger[]
- 1 to 4 suggestions from 1 to 100. Default [10, 15, 20].
- success_urlhttps URL
- Where customers go after paying.
- metadataobject
- Copied onto every checkout the link creates.
A slug that's already in use returns 409 with the code slug_taken. Responses include the link's url and active, and currency is your payout token's symbol.
Webhooks
Set your webhook URL in the dashboard's Developers page. It must be public https; addresses on private networks are refused. The same page shows your signing secret (whsec_…), lets you rotate it, send a test event, see recent deliveries and resend any of them.
Events
- checkout.session.completed
- You were paid. Fulfil the order.
data.objectis the session, with itspayment. - checkout.session.expired
- Nobody paid in time.
data.objectis the session. - checkout.payment.refunded
- A payment failed or was too small and went back to the customer.
data.objectis the payment;data.session_idis its session. - checkout.payment.duplicate
- The customer paid an already-paid session again. Refund them.
data.objectis the payment;data.sessionis the session. - webhook.test
- Sent from the dashboard to check your endpoint.
POST /your/webhook/url
Content-Type: application/json
User-Agent: payanychain-webhooks/1
X-PayAnyChain-Signature: t=1791810000,v1=5f2b...
{
"id": "evt_Qw3...",
"object": "event",
"type": "checkout.session.completed",
"created_at": "2026-10-07T12:01:00.000Z",
"data": { "object": { "id": "cs_...", "status": "paid", ... } }
}Verify the signature
The X-PayAnyChain-Signature header has a timestamp (t) and an HMAC-SHA256 (v1, hex) of <t>.<raw body>, keyed with your whole secret. Compute it over the body exactly as received, compare in constant time, and reject timestamps more than 5 minutes old.
const crypto = require("node:crypto");
// header: the x-payanychain-signature request header
// rawBody: the request body exactly as received (string or Buffer)
function verifyWebhook(secret, rawBody, header, toleranceSeconds = 300) {
if (!header) return false;
const parts = Object.fromEntries(
header.split(",").map((part) => {
const [key, ...value] = part.trim().split("=");
return [key, value.join("=")];
}),
);
const timestamp = Number(parts.t);
if (!Number.isInteger(timestamp) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest();
const actual = Buffer.from(parts.v1, "hex");
return (
actual.length === expected.length &&
crypto.timingSafeEqual(actual, expected)
);
}const express = require("express");
const app = express();
// Use the raw body: re-serialized JSON would not match the signature.
app.post(
"/webhooks/payanychain",
express.raw({ type: "application/json" }),
(req, res) => {
const ok = verifyWebhook(
process.env.PAYANYCHAIN_WEBHOOK_SECRET,
req.body.toString("utf8"),
req.get("x-payanychain-signature"),
);
if (!ok) return res.status(400).send("Bad signature");
const event = JSON.parse(req.body);
// Deliveries can repeat: skip event.id values you've already handled.
if (event.type === "checkout.session.completed") {
const session = event.data.object;
// Fulfil the order for session.client_reference_id
}
res.sendStatus(200);
},
);import hashlib
import hmac
import time
def verify_webhook(secret: str, raw_body: bytes, header: str, tolerance: int = 300) -> bool:
if not header:
return False
parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
try:
timestamp = int(parts["t"])
signature = parts["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - timestamp) > tolerance:
return False
expected = hmac.new(
secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)Delivery and retries
- Return any
2xxwithin 10 seconds. Anything else, including redirects, is a failure. - Failed deliveries retry with growing gaps (30 seconds, then 1, 2, 4… minutes, at most 6 hours apart), 12 attempts over about 15 hours. You can resend any event from the dashboard.
- Deliveries can repeat and arrive out of order. Use the event
idto skip ones you've handled, and check the session'sstatusif order matters. - Rotating the secret takes effect immediately, so update your server right away.
Fees and amounts
- You pay 0.8% per payment, taken from your payout, whichever coin the customer uses. On a
25.00USDC checkout,amount_feeis0.2andamount_net, what arrives in your wallet, is24.8. The fee applies to the whole amount, tip included. - Customers pay your listed price, plus their own network cost (usually about a cent). The checkout shows them one exact amount to send, which includes a small price-protection margin; whatever isn't used goes back to their wallet.
- Amounts are decimal strings in your payout token, never numbers: send
"25.00", not25. Responses drop trailing zeros; useamount_base_unitsfor exact integers. - Each session keeps the fee rate it was created with. There are no monthly, setup or withdrawal fees.
Errors and rate limits
Errors return a JSON body with a stable code:
{ "error": { "code": "invalid_request", "message": "Request validation failed", "issues": { ... } } }- 400 invalid_request
- The request is invalid. Validation failures include
issues, field by field. - 400 invalid_amount
- An amount isn't a plain decimal, or has too many decimals.
- 401 unauthorized
- Missing, wrong or revoked API key.
- 404 not_found
- No such session or link on your account.
- 409 slug_taken
- That payment link name is in use.
- 429 rate_limited
- Too many requests. Wait for the
Retry-Afterheader (in seconds), then retry. - 500 internal_error
- Our fault. Retry with the same Idempotency-Key.
The API allows 300 requests a minute per account, across all your keys. Unknown request fields are ignored.
Testing
PayAnyChain settles on real blockchains, and there's no test network, so test with small real amounts: create a checkout for a dollar or two, pay it from your own wallet, and watch your webhook. The money arrives in your payout wallet like any other payment.
Use the dashboard's Send a test event to check your endpoint and signature code without paying anything.
Questions or stuck? Email [email protected].