# Collect stablecoin payments

Get paid in stablecoins such as USDT and USDC with Payd Stables Checkout. You create a checkout for an amount in US dollars. Your customer opens the link, picks a network and a coin, and sends the payment from their wallet. Payd confirms it, credits your Payd balance and calls your `callback_url`.

<div class="endpoint"><span class="method post">POST</span><code>https://stables.paydlabs.com/api/v1/checkout</code></div>

## How it works

1. You create a checkout with the amount in USD and a `callback_url`.
2. You send the customer to `checkout_url`.
3. The customer picks a network and a coin. The page shows a deposit address and a QR code.
4. The customer sends the exact amount within 30 minutes.
5. Payd waits for the network to confirm the transfer, credits your Payd balance and posts a signed callback.

Stables Checkout uses its own host, `stables.paydlabs.com`, and the same API username and password as the rest of the Payd API.

## Networks and coins

| Network | Coins |
| --- | --- |
| Base | USDC |
| Solana | USDT, USDC |
| Celo | cUSD, USDT, USDC |
| Polygon | USDC, USDT |
| BNB Smart Chain | USDT, USDC, BUSD |
| Tron | USDT |
| Arbitrum | USDC |
| Optimism | USDC |
| Ethereum | USDC, USDT |
| Avalanche | USDT, USDC |
| Stellar | USDC |

The checkout page shows the networks open on your account. Your customer must send the coin on the network they picked: the same coin on another network does not arrive.

## 1. Create a checkout

::: code-group

```bash [cURL]
curl https://stables.paydlabs.com/api/v1/checkout \
  -u "$PAYD_API_USERNAME:$PAYD_API_PASSWORD" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 49.99,
    "payment_method": "stables",
    "callback_url": "https://example.com/webhooks/payd-stables",
    "redirect_url": "https://example.com/orders/8842",
    "email": "amara@example.com",
    "first_name": "Amara",
    "last_name": "Okafor",
    "reason": "Order 8842"
  }'
```

```js [Node.js]
const auth = Buffer.from(
  `${process.env.PAYD_API_USERNAME}:${process.env.PAYD_API_PASSWORD}`,
).toString('base64')

const res = await fetch('https://stables.paydlabs.com/api/v1/checkout', {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    amount: 49.99,
    payment_method: 'stables',
    callback_url: 'https://example.com/webhooks/payd-stables',
    redirect_url: 'https://example.com/orders/8842',
    email: 'amara@example.com',
    reason: 'Order 8842',
  }),
  signal: AbortSignal.timeout(30_000),
})
const checkout = await res.json()
if (!res.ok || !checkout.success) throw new Error(checkout.message)

// Store the reference with your order, then redirect the customer
saveOrderReference('8842', checkout.transaction_reference)
redirect(checkout.checkout_url)
```

```python [Python]
import os
import requests

res = requests.post(
    "https://stables.paydlabs.com/api/v1/checkout",
    auth=(os.environ["PAYD_API_USERNAME"], os.environ["PAYD_API_PASSWORD"]),
    json={
        "amount": 49.99,
        "payment_method": "stables",
        "callback_url": "https://example.com/webhooks/payd-stables",
        "redirect_url": "https://example.com/orders/8842",
        "email": "amara@example.com",
        "reason": "Order 8842",
    },
    timeout=30,
)
res.raise_for_status()
checkout = res.json()

# Store the reference with your order, then redirect the customer
save_order_reference("8842", checkout["transaction_reference"])
redirect(checkout["checkout_url"])
```

:::

### Fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `amount` | number | Yes | Amount in US dollars, more than 0 and up to 1,000,000. |
| `payment_method` | string | Yes | `stables`. |
| `callback_url` | string | Yes | Where Payd posts the result. |
| `redirect_url` | string | No | Where the page sends the customer after they pay. |
| `email` | string | No | The customer's email. |
| `first_name`, `last_name` | string | No | The customer's name. |
| `phone` | string | No | The customer's phone number. |
| `reason` | string | No | What the payment is for, up to 500 characters. The customer sees it. |

### Response

```json
{
  "success": true,
  "status": "success",
  "message": "Payment request sent successfully",
  "payment_method": "stables",
  "checkout_url": "https://stables.paydlabs.com/pay/0b6f3c1e-8a2d-4f5b-9c7e-1d3a5b7c9e2f",
  "transaction_reference": "PLS4F9A1C2BeST"
}
```

Store `transaction_reference`. It ends in `eST` and is how you look the payment up and match the callback to your order.

## 2. Get the result

Payd posts to your `callback_url` when the payment completes, or when the checkout expires without the full amount:

```json
{
  "transaction_reference": "PLS4F9A1C2BeST",
  "result_code": 0,
  "success": true,
  "amount": 49.99,
  "amount_requested": 49.99,
  "amount_received": 49.99,
  "bank_name": "USDC",
  "channel": "base",
  "web3_transaction_reference": "0x8f2a...c41d",
  "transaction_date": "2026-10-16 11:42:03.512204 +0000 UTC",
  "customer_name": "Amara Okafor",
  "forward_url": "https://example.com/webhooks/payd-stables"
}
```

| Field | Meaning |
| --- | --- |
| `transaction_reference` | The reference from the checkout. |
| `result_code` | `0` when the payment completed, `1` when the checkout expired. |
| `success` | `true` when the payment completed. |
| `amount` | What Payd credited to your balance, in USD. Never more than you asked for. |
| `amount_requested`, `amount_received` | What you asked for and what arrived, in USD. |
| `overpayment` | Only when the customer sent too much: the extra amount. |
| `bank_name` | The coin the customer paid with, such as `USDC`. |
| `channel` | The network, such as `base`. |
| `web3_transaction_reference` | The on-chain transaction hash. Use it to look the transfer up in a block explorer. |
| `transaction_date` | When Payd sent the callback, such as `2026-10-16 11:42:03.512204 +0000 UTC`. |

The body has other fields. Ignore them.

### Verify the callback

Every callback has an `X-Payd-Stables-Signature` header: the hex HMAC-SHA256 of the exact body, keyed with your Stables webhook secret. Check it before you trust the callback.

::: code-group

```js [Node.js]
import crypto from 'node:crypto'

export function verifyStables(rawBody, signature, secret) {
  if (!signature) return false
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex')
  return signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
}

// with express.raw({ type: 'application/json' }) on the route:
// verifyStables(req.body, req.get('X-Payd-Stables-Signature'), process.env.PAYD_STABLES_WEBHOOK_SECRET)
```

```python [Python]
import hashlib
import hmac


def verify_stables(raw_body: bytes, signature: str | None, secret: str) -> bool:
    if not signature:
        return False
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature, expected)
```

:::

Get the secret once and keep it with your other secrets:

```bash
curl https://stables.paydlabs.com/api/v1/webhook-secret \
  -u "$PAYD_API_USERNAME:$PAYD_API_PASSWORD"
```

The secret is in `callback_secret`. To change it, call `POST /api/v1/webhook-secret/rotation`: the new secret comes back in `pending_callback_secret`, and the old one keeps signing. Deploy the new secret, then call `POST /api/v1/webhook-secret/rotation/activate`. Accept either secret for a few minutes around the switch. `DELETE /api/v1/webhook-secret/rotation` cancels a rotation you have not activated.

### Delivery

- Answer `200`, `201` or `202` within 15 seconds. Any other answer counts as a failure.
- After a failure Payd tries again after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours, then stops.
- Make your handler safe to run twice: skip a `transaction_reference` you have already handled.

## 3. Check a payment

<div class="endpoint"><span class="method get">GET</span><code>https://stables.paydlabs.com/api/v1/checkout/{transaction_reference}</code></div>

```bash
curl https://stables.paydlabs.com/api/v1/checkout/PLS4F9A1C2BeST \
  -u "$PAYD_API_USERNAME:$PAYD_API_PASSWORD"
```

```json
{
  "reference": "PLS4F9A1C2BeST",
  "status": "completed",
  "amount_requested": 49.99,
  "amount_received": 49.99,
  "currency": "USD",
  "asset": "USDC",
  "chain": "base",
  "deposit_address": "0x3c9e...7a1b",
  "payer_email": "amara@example.com",
  "expires_at": "2026-10-16T12:12:03Z",
  "completed_at": "2026-10-16T11:42:03Z",
  "created_at": "2026-10-16T11:40:51Z"
}
```

To list your checkouts, call `GET /api/v1/checkout?page=1&page_size=20&status=completed`. `page_size` is 20 by default and up to 100.

### Statuses

| `status` | Meaning |
| --- | --- |
| `pending` | The checkout exists. The customer has not picked a network yet. |
| `awaiting_payment` | The customer has a deposit address. Payd is waiting for the transfer. |
| `confirming` | The transfer is on the network. Payd is waiting for confirmations. |
| `completed` | Paid. Your Payd balance is credited. |
| `underpaid` | Less than the amount arrived. Payd waits for the rest until the checkout expires. |
| `overpaid` | More than the amount arrived. Payd credits the amount you asked for. Contact [support](mailto:support@paydhq.com) to return the rest. |
| `expired` | 30 minutes passed without the full amount. If part of it arrived, contact [support](mailto:support@paydhq.com). |
| `failed` | The payment did not go through. |

Payd allows a difference of up to $0.01 between the amount asked and the amount received.

## Tips

- Tell your customer which network and coin they picked, and to send from a wallet that supports it.
- Ask them to send the exact amount in one transfer, and to allow for the network fee on top.
- Do not ship on the redirect alone. Wait for the callback, or check the status.
