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:

  1. Sign in and set up your business: the stablecoin you want to receive and your wallet address.
  2. In Developers, create an API key, and set your webhook URL.
  3. Create a checkout from your server, and redirect to its url:
Create a checkout
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"
  }'
Response (201), shortened
{
  "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",
  ...
}
  1. When the customer pays, your webhook receives checkout.session.completed. Verify its signature and fulfil the order. Don't rely on the redirect to success_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.

Header
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, its payment shows what the customer paid with.
  • GET /api/v1/checkout/sessions?limit=20&before=… lists sessions, newest first, as { "object": "list", "data": [...], "has_more": true }. limit is 1 to 100 (default 20). For the next page, pass the last session's created_at as before.

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_at is 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.

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.object is the session, with its payment.
checkout.session.expired
Nobody paid in time. data.object is the session.
checkout.payment.refunded
A payment failed or was too small and went back to the customer. data.object is the payment; data.session_id is its session.
checkout.payment.duplicate
The customer paid an already-paid session again. Refund them. data.object is the payment; data.session is the session.
webhook.test
Sent from the dashboard to check your endpoint.
Every event looks like this
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.

Node.js
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)
  );
}
Express endpoint
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);
  },
);
Python
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 2xx within 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 id to skip ones you've handled, and check the session's status if 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.00 USDC checkout, amount_fee is 0.2 and amount_net, what arrives in your wallet, is 24.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", not 25. Responses drop trailing zeros; use amount_base_units for 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 response
{ "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-After header (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].