# Payments

> A payment is what a checkout session became. Read it to fulfil orders, list payments to reconcile, and simulate outcomes in Test mode.

Source: https://railbed.io/docs/api/payments/ · Updated: 2026-09-26 · Railbed by DeepWork developer docs

## The payment object

A payment has every field of its [checkout session](https://railbed.io/docs/api/checkout-sessions.md#the-checkout-session-object), with `object` set to `"payment"`, plus:

| Field | Type | Description |
|---|---|---|
| `paid_at` | integer or null | When it became `paid`, in Unix seconds |
| `provider` | string or null | The provider the buyer chose, such as `stripe` or `paypal` |
| `hold_reason` | string or null | Why it's `held`, in plain words. Null otherwise |
| `hold_acceptable` | boolean | For a `held` payment: whether you can accept it as paid in the dashboard |
| `canceled_at` | integer or null | When you canceled it (payment links only). A canceled payment's status is `expired` |
| `settlement` | object | What arrived and where it went. See below |

The `settlement` object:

| Field | Type | Description |
|---|---|---|
| `coin` | string or null | What was delivered, usually `polygon_usdc` (`polygon_usdt` is also possible) |
| `value_coin` | string or null | How much arrived at the payment's deposit address, before fees |
| `merchant_received` | string or null | How much was forwarded to your wallet, in the coin (six decimal places). Can be null for a while after `paid` and fill in later. Stays null for a held payment you accepted as paid |
| `txid_in` | string or null | The Polygon transaction that delivered the money |
| `txid_out` | string or null | The Polygon transaction that forwarded it to your wallet |
| `payout_wallet` | string or null | The wallet it went to, fixed when the buyer started |

`amount` and `currency` are always the price you set; `value_coin` and `merchant_received` are what actually moved. Look up both transactions on a Polygon block explorer to see them for yourself.

## Retrieve a payment

`GET /v1/payments/:id`

Returns the current state of a payment of yours in the key's mode. This is the authority for fulfilment: when it says `paid`, the money arrived and passed Railbed's [settlement checks](https://railbed.io/docs/webhooks/events.md#payment-paid).

cURL:

```bash
curl https://pay.railbed.io/v1/payments/pay_7AAiYH0Ykt11ED4hmfiN \
  -H "Authorization: Bearer $RAILBED_SECRET_KEY"
```

Node.js:

```js
const payment = await fetch(`https://pay.railbed.io/v1/payments/${id}`, {
  headers: { Authorization: `Bearer ${process.env.RAILBED_SECRET_KEY}` },
}).then((r) => r.json());
```

Python:

```python
payment = requests.get(
    f"https://pay.railbed.io/v1/payments/{id}",
    headers={"Authorization": f"Bearer {os.environ['RAILBED_SECRET_KEY']}"},
    timeout=15,
).json()
```

PHP:

```php
<?php
$ch = curl_init('https://pay.railbed.io/v1/payments/' . rawurlencode($id));
curl_setopt_array($ch, [
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER => [
    'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'),
  ],
]);
$payment = json_decode(curl_exec($ch), true);
```

Response · response 200 OK:

```json
{
  "id": "pay_7AAiYH0Ykt11ED4hmfiN",
  "object": "payment",
  "url": "https://pay.railbed.io/p/pay_7AAiYH0Ykt11ED4hmfiN",
  "status": "paid",
  "livemode": true,
  "amount": "49.00",
  "currency": "USD",
  "description": "Pro Membership",
  "reference": "order_1042",
  "customer_email": "buyer@example.com",
  "created": 1790380525,
  "expires_at": 1790466925,
  "started_at": 1790380611,
  "metadata": { "user_id": "player_1042" },
  "paid_at": 1790381342,
  "provider": "stripe",
  "hold_reason": null,
  "hold_acceptable": false,
  "canceled_at": null,
  "settlement": {
    "coin": "polygon_usdc",
    "value_coin": "47.53",
    "merchant_received": "46.341750",
    "txid_in": "0x0c651ba1d59c7a32e8b1f4bd2c7e0e4f96a55d13a6b0f2d1c8e7a9b4f3d29b58",
    "txid_out": "0x8202d1373e0a9c4f1b6d5e2c7a8f9b0e1d2c3b4a5f6e7d8c9b0a1f2e3d7356ff",
    "payout_wallet": "0xF977814e90dA44bFA03b6295A0616a897441aceC"
  }
}
```

A held payment looks like this in part:

A held payment (trimmed) · response 200 OK:

```json
{
  "id": "pay_Kx81mQv2PzR0dT7eWcYa",
  "object": "payment",
  "status": "held",
  "amount": "49.00",
  "currency": "USD",
  "paid_at": null,
  "hold_reason": "The provider sent 24.50 USDC, below 90% of the order’s 49.00 USD value.",
  "hold_acceptable": true,
  "settlement": { "coin": "polygon_usdc", "value_coin": "24.50", "merchant_received": null }
}
```

Reading a payment past its `expires_at` marks an unpaid one `expired`.

## List payments

`GET /v1/payments`

Returns your payments in the key's mode, newest first. Use it to reconcile, not to find new payments quickly: it lists by creation time, so recheck unfinished payments you saved by id.

| Parameter | Type | Description |
|---|---|---|
| `limit` | integer | 1–100. Default 20 |
| `starting_after` | string | A payment id from the previous page's `next_cursor` |

cURL:

```bash
curl "https://pay.railbed.io/v1/payments?limit=50" \
  -H "Authorization: Bearer $RAILBED_SECRET_KEY"
```

Node.js:

```js
// Every payment, page by page
let cursor = null;
do {
  const url = new URL('https://pay.railbed.io/v1/payments');
  url.searchParams.set('limit', '100');
  if (cursor) url.searchParams.set('starting_after', cursor);
  const page = await fetch(url, {
    headers: { Authorization: `Bearer ${process.env.RAILBED_SECRET_KEY}` },
  }).then((r) => r.json());
  for (const payment of page.data) reconcile(payment);
  cursor = page.next_cursor;
} while (cursor);
```

Python:

```python
cursor = None
while True:
    params = {"limit": 100, **({"starting_after": cursor} if cursor else {})}
    page = requests.get(
        "https://pay.railbed.io/v1/payments",
        headers={"Authorization": f"Bearer {os.environ['RAILBED_SECRET_KEY']}"},
        params=params,
        timeout=15,
    ).json()
    for payment in page["data"]:
        reconcile(payment)
    cursor = page["next_cursor"]
    if not cursor:
        break
```

PHP:

```php
<?php
$cursor = null;
do {
  $query = http_build_query(array_filter([
    'limit' => 100,
    'starting_after' => $cursor,
  ]));
  $ch = curl_init('https://pay.railbed.io/v1/payments?' . $query);
  curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
      'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'),
    ],
  ]);
  $page = json_decode(curl_exec($ch), true);
  foreach ($page['data'] as $payment) reconcile($payment);
  $cursor = $page['next_cursor'];
} while ($cursor);
```

Response · response 200 OK:

```json
{
  "data": [
    {
      "id": "pay_7AAiYH0Ykt11ED4hmfiN",
      "object": "payment",
      "status": "paid",
      "amount": "49.00",
      "currency": "USD"
    },
    {
      "id": "pay_Kx81mQv2PzR0dT7eWcYa",
      "object": "payment",
      "status": "held",
      "amount": "49.00",
      "currency": "USD"
    }
  ],
  "has_more": true,
  "next_cursor": "pay_Kx81mQv2PzR0dT7eWcYa"
}
```

Each item is a full [payment object](#the-payment-object) (trimmed here). An invalid `limit` is `400 invalid_limit`; a cursor that isn't one of your payments in this mode is `400 invalid_cursor`.

## Simulate a payment

`POST /v1/payments/:id/simulate`

**Test mode only.** Decides how a started test payment ends, as a buyer on the test provider page would. The outcome goes through the same settlement checks and sends the same webhooks as a live payment.

| Field | Type | Description |
|---|---|---|
| `outcome` | string · required | `paid` (about 97% of the value arrives), `underpaid` (half arrives, so it's `held`) or `failed` (declined) |

cURL:

```bash
curl -X POST \
  https://pay.railbed.io/v1/payments/pay_7AAiYH0Ykt11ED4hmfiN/simulate \
  -H "Authorization: Bearer $RAILBED_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "outcome": "paid" }'
```

Node.js:

```js
const payment = await fetch(`https://pay.railbed.io/v1/payments/${id}/simulate`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.RAILBED_SECRET_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ outcome: 'paid' }),
}).then((r) => r.json());
```

Python:

```python
payment = requests.post(
    f"https://pay.railbed.io/v1/payments/{id}/simulate",
    headers={"Authorization": f"Bearer {os.environ['RAILBED_SECRET_KEY']}"},
    json={"outcome": "paid"},
    timeout=15,
).json()
```

PHP:

```php
<?php
$ch = curl_init(
  'https://pay.railbed.io/v1/payments/' . rawurlencode($id) . '/simulate'
);
curl_setopt_array($ch, [
  CURLOPT_POST => true,
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER => [
    'Authorization: Bearer ' . getenv('RAILBED_SECRET_KEY'),
    'Content-Type: application/json',
  ],
  CURLOPT_POSTFIELDS => json_encode(['outcome' => 'paid']),
]);
$payment = json_decode(curl_exec($ch), true);
```

The response is the [payment](#the-payment-object) after the outcome. A payment can end only once: simulating a finished payment returns it as it is. Use a new session for each scenario.

| Status | Code | When |
|---|---|---|
| 400 | `invalid_outcome` | `outcome` is missing or not one of the three |
| 403 | `live_payment` | The payment is a Live payment |
| 409 | `not_started` | Start the session first, with [Start a checkout session](https://railbed.io/docs/api/checkout-sessions.md#start-a-checkout-session) |
