# Event types

> Every webhook event Railbed sends, when it's sent, what the payment looks like at that moment, and the full payload reference.

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

## The event object

Every delivery's body is one event:

payment.paid · response Delivered · 200:

```json
{
  "id": "evt_4Qm8ZsUe2VhNc7RwTb1Y",
  "type": "payment.paid",
  "created": 1790381342,
  "livemode": true,
  "data": {
    "payment": {
      "id": "pay_7AAiYH0Ykt11ED4hmfiN",
      "mode": "live",
      "source": "api",
      "checkoutId": null,
      "planLabel": null,
      "description": "Pro Membership",
      "reference": "order_1042",
      "customerEmail": "buyer@example.com",
      "amount": "49.00",
      "currency": "USD",
      "status": "paid",
      "provider": "stripe",
      "providerName": "Stripe",
      "depositAddress": "0x5b0e8a3f2d1c4b7a9e6f0d3c2b1a4e7f8d9c0b1a",
      "payoutWallet": "0xF977814e90dA44bFA03b6295A0616a897441aceC",
      "feeBps": 150,
      "valueCoin": "47.53",
      "merchantReceived": "46.341750",
      "coin": "polygon_usdc",
      "holdReason": null,
      "holdAcceptable": false,
      "txidIn": "0x0c651ba1d59c7a32e8b1f4bd2c7e0e4f96a55d13a6b0f2d1c8e7a9b4f3d29b58",
      "txidOut": "0x8202d1373e0a9c4f1b6d5e2c7a8f9b0e1d2c3b4a5f6e7d8c9b0a1f2e3d7356ff",
      "metadata": { "user_id": "player_1042" },
      "customerName": null,
      "memo": null,
      "trackingUrl": null,
      "canceledAt": null,
      "successUrl": "https://yourstore.com/thanks?order={REFERENCE}&payment={PAYMENT_ID}",
      "createdAt": 1790380525000,
      "paidAt": 1790381342000,
      "expiresAt": 1790466925000,
      "url": "https://pay.railbed.io/p/pay_7AAiYH0Ykt11ED4hmfiN"
    }
  }
}
```

| Field | Type | Description |
|---|---|---|
| `id` | string | The event's id, `evt_…`. The same on every attempt and resend. **Deduplicate on this** |
| `type` | string | One of the types below |
| `created` | integer | When the event happened, in Unix **seconds** |
| `livemode` | boolean | `true` for Live events, `false` for Test |
| `data.payment` | object or null | The [payment](#the-payment-object) as it was when the event happened. `null` for `ping` |

The payment in an event is a snapshot. Events can arrive late or out of order, so when you need the current state, [read the payment](https://railbed.io/docs/api/payments.md#retrieve-a-payment).

## Events

### payment.started

The buyer entered their email and was given a way to pay: Railbed assigned the payment's deposit address and locked the fee and the order's USD value. The payment is still `open` (it becomes `pending` when the buyer reaches a provider), and `provider` is usually still `null`.

Use it for abandoned-checkout follow-ups, or to show "awaiting payment" in your system. Never fulfil from it.

payment.started (trimmed):

```json
{
  "id": "evt_9sPq2XbLr5TtVn0KcWmE",
  "type": "payment.started",
  "created": 1790380611,
  "livemode": true,
  "data": {
    "payment": {
      "id": "pay_7AAiYH0Ykt11ED4hmfiN",
      "status": "open",
      "customerEmail": "buyer@example.com",
      "amount": "49.00",
      "currency": "USD",
      "provider": null,
      "depositAddress": "0x5b0e8a3f2d1c4b7a9e6f0d3c2b1a4e7f8d9c0b1a",
      "feeBps": 150
    }
  }
}
```

In Test mode, `depositAddress` is `null`: no real address is created.

### payment.paid

The money arrived and passed Railbed's checks: at least 90% of the order's value in a dollar coin and, from the processor's signed notice, the right deposit address, your wallet in the payout and a transaction never used before. Or you [accepted a held payment](#payment-held) as paid. **This is the event to fulfil from.**

- The full payload is the example in [The event object](#the-event-object).
- `status` is `paid` and `paidAt` is set.
- `valueCoin`, `coin` and `txidIn` say what arrived; `txidOut` and `merchantReceived` say what was forwarded to you.
- If the processor's notice is late, Railbed's scheduled status check can confirm the payment on the coin and amount first. The notice's details are checked when it arrives and fill in `txidIn` and `merchantReceived` in a [`payment.updated`](#payment-updated); a notice that doesn't match is flagged on the payment's timeline in the dashboard.
- For a held payment you accepted, `merchantReceived` stays `null` and no `payment.updated` follows.

### payment.held

Money arrived but failed a check, most often because less arrived than 90% of the order's value. **Don't fulfil.** The payment waits for you in the dashboard.

- `holdReason` says what failed, in plain words.
- `holdAcceptable` says whether you can **Accept as paid** in the dashboard. It's `false` when the evidence shows the money went somewhere else, for example to a wallet that isn't yours.
- If you accept it, a `payment.paid` follows for the same payment.

payment.held (trimmed):

```json
{
  "id": "evt_Hq3nW8ZkT1cVbR6sYp0M",
  "type": "payment.held",
  "created": 1790381342,
  "livemode": true,
  "data": {
    "payment": {
      "id": "pay_Kx81mQv2PzR0dT7eWcYa",
      "status": "held",
      "amount": "49.00",
      "currency": "USD",
      "provider": "stripe",
      "valueCoin": "24.50",
      "coin": "polygon_usdc",
      "holdReason": "The provider sent 24.50 USDC, below 90% of the order’s 49.00 USD value.",
      "holdAcceptable": true,
      "paidAt": null
    }
  }
}
```

### payment.updated

A paid payment's settlement details arrived after it was confirmed: `txidIn`, `valueCoin`, `coin` and `merchantReceived` are now filled in. The status stays `paid`. Update your records; there's nothing to fulfil again.

### payment.failed

The payment was declined. Today this is sent in **Test mode only**, when you simulate a decline: live card providers don't report declines, so a live payment the buyer never completes ends as [`payment.expired`](#payment-expired). Let the buyer try again with a new session.

### payment.expired

Nobody paid before the payment's `expiresAt`: 24 hours for API sessions, or the lifetime chosen for a payment link. It's sent within five minutes of expiry.

It's not always the end. A provider that delivers late still settles the payment, and a `payment.paid` or `payment.held` follows. Release reserved stock if you like, but keep the order able to complete.

### payment.canceled

You canceled a payment link in the dashboard before anyone had gone to a provider. The payment's `status` is `expired` and `canceledAt` is set.

### ping

Sent only when you choose **Send test event** and pick `ping` (Test endpoints) or **Send ping** (Live endpoints). `data.payment` is `null`. Use it to check the address, the secret and your signature code.

ping:

```json
{
  "id": "evt_T2rVx9KcQm4NwLb7Ez0P",
  "type": "ping",
  "created": 1790380800,
  "livemode": false,
  "data": { "payment": null }
}
```

## Typical sequences

| What happened | Events, in order |
|---|---|
| A normal payment | `payment.started` → `payment.paid` (→ `payment.updated`) |
| An underpayment you accept | `payment.started` → `payment.held` → `payment.paid` |
| A buyer who never pays | `payment.started` → `payment.expired` |
| A buyer who never started | `payment.expired` |
| A late payment | `payment.started` → `payment.expired` → `payment.paid` or `payment.held` |
| A canceled payment link | `payment.canceled` |
| A declined test payment | `payment.started` → `payment.failed` |

Events can arrive out of order, so handle each one on its own merits, and never undo a `paid` order because of a later event.

## The payment object

Webhook payloads carry the payment in the dashboard's shape: camelCase names and timestamps in **milliseconds** (the event's own `created` is Unix seconds). The [API's payment](https://railbed.io/docs/api/payments.md#the-payment-object) has the same facts in snake_case with Unix seconds.

| Field | Type | Description |
|---|---|---|
| `id` | string | The payment's id, `pay_…`. The same id the API uses |
| `mode` | string | `live` or `test` |
| `source` | string | `api` (the API), `checkout` (a checkout page, pricing table or widget) or `payment_link` |
| `checkoutId` | string or null | The checkout it came from, `chk_…`, for `checkout` payments |
| `planLabel` | string or null | For pricing tables: the plan and price the buyer chose, such as `Pro · Yearly` |
| `description` | string | What the buyer is paying for |
| `reference` | string or null | Your reference, as sent when creating the session |
| `customerEmail` | string or null | The email the buyer entered. Buyers can type any address, so don't use it to identify an account |
| `amount` | string | The price you set, as a decimal string |
| `currency` | string | The price's currency |
| `status` | string | `open`, `pending`, `paid`, `held`, `failed` or `expired`. See [statuses](https://railbed.io/docs/how-it-works.md#statuses) |
| `provider` | string or null | The provider the buyer chose, such as `stripe` |
| `providerName` | string or null | Its display name, such as `Stripe` |
| `depositAddress` | string or null | The one-time Polygon address this payment is paid into. Set when a Live payment starts |
| `payoutWallet` | string or null | Your wallet the payment is forwarded to, fixed when it starts |
| `feeBps` | integer | The Railbed fee locked into this payment, in basis points of what arrives (150 = 1.5%) |
| `valueCoin` | string or null | How much arrived at the deposit address, in `coin` |
| `merchantReceived` | string or null | How much was forwarded to your wallet, in `coin`. `null` until it's reported; never estimated |
| `coin` | string or null | What arrived, usually `polygon_usdc` |
| `holdReason` | string or null | Why the payment is held, in plain words |
| `holdAcceptable` | boolean | For a held payment: whether you can accept it as paid |
| `txidIn` | string or null | The Polygon transaction that delivered the money |
| `txidOut` | string or null | The Polygon transaction that forwarded it to you |
| `metadata` | object or null | Your metadata, as sent when creating the session |
| `customerName` | string or null | Payment links: who it's for |
| `memo` | string or null | Payment links: the note to the payer |
| `trackingUrl` | string or null | Payment links: the read-only tracking page |
| `canceledAt` | integer or null | When you canceled the payment link, in ms |
| `successUrl` | string or null | The success URL as you set it, with its `{PAYMENT_ID}` and `{REFERENCE}` placeholders left in (percent-encoded if they're in the path). The buyer's checkout fills them in |
| `createdAt` | integer | When the payment was created, in ms |
| `paidAt` | integer or null | When it became `paid`, in ms |
| `expiresAt` | integer | When an unpaid payment expires, in ms |
| `url` | string | The payment's hosted checkout page |

> [!TIP]
> Want one shape everywhere? Treat the webhook as a signal: verify it, then fetch `GET /v1/payments/:id` and run all your checks on the API's answer.
