API reference
Overview
Switch Pay lets shoppers pay from Cash App, Venmo, PayPal, Coinbase, Robinhood or any crypto wallet. You create a checkout session from your server, send the shopper to the hosted checkout, and we POST a signed webhook to your server when the payment confirms. Funds settle to your balance in USD and are paid out once a day in USDC.
- Requests and responses are JSON. Send
Content-Type: application/json; bodies are limited to 10 KB. - Money and crypto amounts are returned as strings (for example
"49.99"). Timestamps are ISO-8601 in UTC. - IDs: checkout sessions start with
cps_, payments withcpay_, refunds withcpay_rfd_, merchants withsp_merch_. - Call the API from your server only. Never put an API key in browser or app code.
Authentication
Send your API key as a Bearer token on every authenticated request:
Authorization: Bearer YOUR_API_KEY
Keys are issued per merchant, together with an HMAC secret that signs your webhooks. You get both from your Switch Pay dashboard (Integrations) or from support; the secret is shown once, so store it with the key. A key can only create and read payments for the merchant it belongs to. Revoke or rotate a key from the dashboard at any time.
| Status | error | When |
|---|---|---|
| 401 | unauthorized | Missing header, not Bearer <key>, or an unknown or revoked key |
| 403 | merchant_scope_required | The endpoint needs a merchant key (checkout sessions) |
| 403 | merchant_mismatch | The body names a different merchant than the key |
Quick start
- Create a checkout session for the order.
- Send the shopper to
checkout_url(redirect, new tab or an iframe). - Receive the
confirmedwebhook, verify its signature, mark the order paid.
curl https://api.swtchpay.com/api/v1/checkout-sessions \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"transaction_id": "order_10422",
"amount_usd": 49.99,
"callback_url": "https://shop.example.com/switchpay/webhook"
}'
Checkout sessions
A checkout session is the recommended integration: the shopper picks an app on our hosted page and we create the payment for them. The WooCommerce plugin uses the same API.
Create a session
| Field | Type | Notes |
|---|---|---|
transaction_id | string, required | Your order id, 1 to 255 characters. Unique per merchant; repeating it returns the same session (see Idempotency). |
amount_usd | number, required | The order amount in USD, greater than 0 and at most 1,000,000, with up to 2 decimals. Send a JSON number, not a string. The shopper pays exactly this amount. |
callback_url | string, optional | Public HTTPS URL that receives the webhooks for this session's payments. It must resolve in DNS to a public address. Without it, no webhooks are sent. |
enabled_methods | array, optional | Limit the app tiles shown: cashapp, venmo, paypal, coinbase, robinhood, wallet (bitcoin is coming soon). Omit to use the methods enabled on your account. |
enabled_currencies | array, optional | Limit the currencies offered (see currencies). Codes that are not accepted yet are dropped. |
metadata | object, optional | String keys and string values stored with the session for your records. It is not included in API responses or webhooks. |
Unknown fields are ignored. Sessions last 30 minutes, or one year when every offered currency is a stablecoin (the usual case); an expired session restarts cleanly if the shopper comes back.
Response
201 Created for a new session, 200 OK with "idempotent": true when the transaction_id already has one.
{
"session_id": "cps_9b1e04c7a2f34d6e8c55",
"checkout_url": "https://api.swtchpay.com/checkout/cps_9b1e04c7a2f34d6e8c55",
"status": "awaiting_currency",
"amount_usd": "49.99",
"enabled_currencies": ["USDC_SOL", "USDC_ETH", "USDC_BASE", "PYUSD_SOL"],
"enabled_methods": ["cashapp", "venmo"],
"expires_at": "2027-09-28T17:00:00.000Z",
"payment_id": null,
"idempotent": false
}
Errors: 400 validation_error, 403 merchant_scope_required, 422 currency_not_accepted (nothing you asked for is accepted), 429, 500.
Retrieve a session
{
"session_id": "cps_9b1e04c7a2f34d6e8c55",
"status": "currency_selected",
"amount_usd": "49.99",
"enabled_currencies": ["USDC_SOL", "USDC_ETH", "USDC_BASE", "PYUSD_SOL"],
"enabled_methods": ["cashapp", "venmo"],
"expires_at": "2027-09-28T17:00:00.000Z",
"payment_id": "cpay_5c0d2e91aa7b4f13b6e0"
}
| Session status | Meaning |
|---|---|
awaiting_currency | The shopper has not picked an app yet (payment_id is null) |
currency_selected | A payment exists; follow it with payment_id or your webhook |
If the shopper switches to another app, the session gets a new payment_id, and that payment's transaction_id carries a suffix: order_10422_v2, order_10422_v3 and so on. Strip _v<n> to match your order.
Hosted page and embedding
Send the shopper to checkout_url, open it in a new tab, or load it in an iframe on your checkout page (framing is allowed from any site). Add ?method=cashapp (or any enabled method) to skip the app picker. The page shows the amount, the address, a QR code, step-by-step instructions for the chosen app, and watches the network live.
When the page is embedded, it tells your page the outcome with postMessage:
window.addEventListener('message', (e) => {
if (e.origin !== 'https://api.swtchpay.com') return;
if (e.data?.type === 'switchpay:confirmed') showThankYou(e.data.payment_id);
if (e.data?.type === 'switchpay:expired' || e.data?.type === 'switchpay:failed') offerRetry();
});
Treat these events as a user-interface signal only. Mark orders paid from the signed webhook.
Payments
Create a payment directly when you already know the currency, for example in your own checkout UI. Most integrations use checkout sessions instead.
Create a payment
| Field | Type | Notes |
|---|---|---|
merchant_id | string, required | Your merchant id (sp_merch_...). It must match your key. |
transaction_id | string, required | Your order id. Unique per merchant (see Idempotency). |
amount_usd | number, required | Greater than 0, at most 1,000,000, up to 2 decimals. |
currency | string, required | An accepted currency code, for example USDC_SOL. |
callback_url | string, optional | Public HTTPS webhook URL. Without it, no webhooks are sent. |
customer_id | string, optional | Stored for your records. |
Response 201 Created
{
"payment_id": "cpay_3f9c2a7e1b4d4c0e9a12",
"status": "awaiting_payment",
"wallet_address": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
"crypto_amount": "49.990000",
"currency": "USDC_SOL",
"exchange_rate": "0.999812",
"qr_code": "data:image/png;base64,iVBORw0KGgo...",
"qr_page_url": "https://api.swtchpay.com/api/v1/payments/cpay_3f9c2a7e1b4d4c0e9a12/qr",
"qr_image_url": "https://api.swtchpay.com/api/v1/payments/cpay_3f9c2a7e1b4d4c0e9a12/qr?raw=1",
"expires_at": "2027-09-28T17:00:00.000Z",
"amount_usd": "49.99"
}
crypto_amountis the exact amount the shopper must send towallet_address. Send the shopper toqr_page_url, or show the address, the amount andqr_codeyourself.exchange_rateis the live USD price of the coin when the payment was created.- A repeated
transaction_idreturns200 OKwith the existing payment and"idempotent": true.
Errors: 400 validation_error, 403 merchant_mismatch, 422 currency_not_accepted, 503 price_unavailable (retry in a minute), 429, 500.
Retrieve a payment
{
"payment_id": "cpay_3f9c2a7e1b4d4c0e9a12",
"status": "confirmed",
"merchant_id": "sp_merch_01002",
"transaction_id": "order_10422",
"amount_usd": "49.99",
"currency": "USDC_SOL",
"crypto_amount": "49.990000000000000000",
"exchange_rate": "0.99981200",
"wallet_address": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
"failure_code": null,
"expires_at": "2027-09-28T17:00:00.000Z",
"confirmed_at": "2026-09-28T17:03:10.512Z",
"created_at": "2026-09-28T17:00:00.101Z"
}
Payment status
{ "payment_id": "cpay_3f9c2a7e1b4d4c0e9a12", "status": "awaiting_payment", "failure_code": null, "confirmed_at": null }
Both return 404 not_found for a payment that does not exist or belongs to another merchant. They read our records; they do not check the network themselves. Webhooks are the fastest way to learn about a confirmation.
Payment page and live status
The hosted payment page for one payment: amount, QR code, address, copy buttons, instructions and a live tracker. Add ?embed=1 for an iframe version without page chrome, or ?raw=1 for just the QR code as a PNG image.
{ "payment_id": "cpay_5c0d2e91aa7b4f13b6e0", "status": "awaiting_payment", "expires_at": "2027-09-28T17:00:00.000Z", "checked_at": "2026-09-28T17:01:02.000Z", "seen": null }
Add ?check=1 to have us look at the network right away (rate-limited per payment). This is what our own pages poll; use it for a waiting screen, not to mark orders paid.
Account
Who a key belongs to and which currencies you can accept right now. Useful to validate a key when someone pastes it into your settings.
{
"provider_id": null,
"provider_name": "Switch Pay",
"merchant_id": "sp_merch_01002",
"merchant_name": "Acme Coffee",
"key_scope": "merchant",
"supported_currencies": ["USDC_SOL", "PYUSD_SOL", "USDT_SOL", "SOL", "USDC_BASE", "ETH_BASE", "USDC_ETH", "USDT_ETH", "ETH"]
}
Audit log
The API requests made with this key: payment and checkout-session calls, newest first, with 24-hour totals. Query parameters: limit (default 50, at most 500), since (ISO-8601 timestamp), path (text the URL contains), status (4xx, 5xx or an exact code). GET /api/v1/audit/live returns the last 10 requests.
{
"stats_24h": { "total_requests": "42", "unique_ips": "2", "avg_latency_ms": 38, "max_latency_ms": 412, "error_count": "3",
"first_request": "2026-09-27T18:02:11.000Z", "last_request": "2026-09-28T16:59:40.000Z" },
"entries": [
{ "id": "10231", "timestamp": "2026-09-28T16:59:40.000Z", "method": "POST", "path": "/api/v1/checkout-sessions",
"status_code": 201, "latency_ms": 41, "ip_address": "203.0.113.7", "user_agent": "WordPress/6.6",
"request_body": { "transaction_id": "order_10422", "amount_usd": 49.99 }, "error": null, "payment_id": null, "idempotent": false }
],
"count": 1,
"filters": { "limit": 50 }
}
Payment methods and currencies
Methods are the app tiles shoppers see. Each method pays in a currency on a specific network; the checkout picks the best one you have enabled.
| Method | Shopper sends |
|---|---|
cashapp | USDC (Solana, falls back to Ethereum or Base) |
venmo | PYUSD on Solana |
paypal | PYUSD on Solana |
coinbase | USDC on Base (falls back to Solana or Ethereum) |
robinhood | USDC on Solana (falls back to Base or Ethereum) |
wallet | Any accepted currency; the shopper picks the network |
bitcoin | Coming soon |
| Currency | Network | Type | Payment expires |
|---|---|---|---|
USDC_SOL | Solana | Stablecoin | 1 year |
PYUSD_SOL | Solana | Stablecoin | 1 year |
USDT_SOL | Solana | Stablecoin | 1 year |
SOL | Solana | Native | 30 minutes |
USDC_BASE | Base | Stablecoin | 1 year |
ETH_BASE | Base | Native | 30 minutes |
USDC_ETH | Ethereum | Stablecoin | 1 year |
USDT_ETH | Ethereum | Stablecoin | 1 year |
ETH | Ethereum | Native | 30 minutes |
BTC, PYUSD_ETH | Bitcoin, Ethereum | Coming soon |
- Amounts. Stablecoins are 1:1 with USD, so a $49.99 order asks for exactly 49.99. Native coins (SOL, ETH) are quoted from the live price with a small allowance for price movement during the payment window, rounded up.
- Tolerance. A payment confirms when at least 98% of
crypto_amountarrives. Less than that is an underpayment (failed). More is accepted. - What you can accept today is always in
supported_currenciesfrom GET /api/v1/account.
Statuses
| Payment status | failure_code | Meaning |
|---|---|---|
awaiting_payment | Waiting for the shopper's transfer | |
confirmed | The amount arrived on the network. Fulfil the order. | |
failed | UNDERPAYMENT | Less than the required amount arrived. Final; contact the shopper or refund from the dashboard. |
expired | EXPIRED | Nothing arrived before expires_at. The shopper can start a new checkout. |
Refunds do not change a payment's status; they are reported by refund webhooks.
Webhooks
We POST JSON to the callback_url of the payment (or of its checkout session) when a payment reaches a final status and when a refund completes. Webhooks are sent at least once: handle duplicates by payment_id and status (or refund_id).
Payment events
POST https://shop.example.com/switchpay/webhook
Content-Type: application/json
X-SwitchPay-Signature: 3b1f0c...e9
X-SwitchPay-Payment-Id: cpay_5c0d2e91aa7b4f13b6e0
X-SwitchPay-Attempt: 1
{
"payment_id": "cpay_5c0d2e91aa7b4f13b6e0",
"status": "confirmed",
"merchant_id": "sp_merch_01002",
"transaction_id": "order_10422",
"amount_usd": "49.99",
"currency": "PYUSD_SOL",
"crypto_amount": "49.990000000000000000",
"wallet_address": "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM",
"tx_hash": "4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZAMdL4VZHirAn...",
"received_amount": "49.99",
"confirmed_at": "2026-09-28T17:03:10.512Z"
}
| Field | Sent for | Notes |
|---|---|---|
status | all | confirmed, failed or expired |
transaction_id | all | May end in _v2, _v3 after an app switch |
amount_usd, currency, crypto_amount, wallet_address | all | As created |
tx_hash | confirmed, failed | The network transaction when known; otherwise our internal reference |
received_amount | confirmed, failed | In the payment currency |
confirmed_at | confirmed | |
failure_code | failed, expired | UNDERPAYMENT or EXPIRED |
Refund events
X-SwitchPay-Event-Type: refund.partially_refunded
X-SwitchPay-Refund-Id: cpay_rfd_8e3b1c0a7d2f4e19
{
"event_type": "refund.partially_refunded",
"refund_id": "cpay_rfd_8e3b1c0a7d2f4e19",
"payment_id": "cpay_5c0d2e91aa7b4f13b6e0",
"merchant_id": "sp_merch_01002",
"transaction_id": "order_10422",
"refund_amount_usd": "10.00",
"total_refunded_usd": "10.00",
"original_amount_usd": "49.99",
"destination_wallet": "0x9f2c...",
"tx_hash": "0xab12...",
"reason": "Damaged item",
"confirmed_at": "2026-09-28T18:10:00.000Z"
}
event_type is refund.completed once the payment is fully refunded, otherwise refund.partially_refunded.
Delivery and retries
- We wait up to 10 seconds and do not follow redirects. Reply with a 2xx status quickly and do the work afterwards.
- Any status below 500 counts as delivered. A 5xx, a timeout or a connection error is retried 30 seconds, 2 minutes, 10 minutes, 1 hour, 4 hours and 24 hours later (7 attempts in total).
- The URL must be public HTTPS; it is checked again on every attempt.
Verifying signatures
X-SwitchPay-Signature is the lowercase hex HMAC-SHA256 of the raw request body, keyed with the HMAC secret issued with your API key. Compute it over the exact bytes you received (before parsing JSON) and compare in constant time. After a key rotation, webhooks are signed with the newest key's secret.
// Node (Express: use express.raw({ type: 'application/json' }) on this route)
const crypto = require('crypto');
const expected = crypto.createHmac('sha256', HMAC_SECRET).update(req.body).digest('hex');
const got = String(req.get('X-SwitchPay-Signature') || '');
const ok = got.length === 64 && crypto.timingSafeEqual(Buffer.from(got, 'hex'), Buffer.from(expected, 'hex'));
// PHP
$raw = file_get_contents('php://input');
$expected = hash_hmac('sha256', $raw, $hmacSecret);
$ok = hash_equals($expected, $_SERVER['HTTP_X_SWITCHPAY_SIGNATURE'] ?? '');
# Python
expected = hmac.new(HMAC_SECRET.encode(), raw_body, hashlib.sha256).hexdigest()
ok = hmac.compare_digest(expected, request.headers.get('X-SwitchPay-Signature', ''))
Refunds
Refunds are issued from your Switch Pay dashboard, not the API. A confirmed payment can be refunded in full or in parts, up to its amount. The refund goes back to the address the shopper paid from, and a refund webhook is sent when it completes. See the refund policy.
Idempotency
Your transaction_id is the idempotency key, unique per merchant. Creating a session or payment again with the same transaction_id returns the existing one with 200 OK and "idempotent": true, so retrying after a timeout is always safe. The other fields of the repeated request are ignored: to charge a different amount, use a new transaction_id.
Rate limits
Each API key has a per-minute limit (100 to 120 requests) shared across its endpoints, counted in fixed one-minute windows. Every response carries X-RateLimit-Limit and X-RateLimit-Remaining. Over the limit you get:
HTTP/1.1 429 Too Many Requests
{ "error": "rate_limit_exceeded", "message": "Rate limit of 120 requests per 60s exceeded", "retry_after": 60 }
Wait for the next minute and retry. Need more? Ask support.
Errors
Errors return a JSON body with a machine-readable error and usually a human-readable message. Validation errors add details listing each problem.
{ "error": "validation_error", "message": "Invalid request body", "details": [ { "path": ["amount_usd"], "message": "Invalid input: expected number, received string" } ] }
| Status | error | Meaning |
|---|---|---|
| 400 | validation_error | A field is missing or invalid, or callback_url is not public HTTPS |
| 401 | unauthorized | Missing or invalid API key |
| 403 | merchant_mismatch, merchant_scope_required | The key cannot act for that merchant, or the endpoint needs a merchant key |
| 404 | not_found | Unknown id, or it belongs to another merchant |
| 409 | session_unusable | The checkout session can no longer be used |
| 422 | currency_not_accepted | The currency (or every requested one) is not accepted yet |
| 429 | rate_limit_exceeded | Slow down; see Rate limits |
| 500 | internal_error | Something went wrong on our side; retry, it is safe with the same transaction_id |
| 503 | price_unavailable | No live price for that coin right now; retry in a minute |
Health
{ "status": "ok", "timestamp": "2026-09-28T17:00:00.000Z" }
Questions or an integration this page does not cover: [email protected].