# Event types

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

Source: https://railbed.com/docs/webhooks/events/ · Updated: 2026-10-04 · Railbed by DeepWork developer docs

For AI assistants: the index of every docs page, with the rules for an integration, is https://railbed.com/docs/llms.txt

## The event object

Every delivery's body is one event:

Example: 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",
      "orderId": "ord_M4tQx8ZkP1vRn6WcLs2B"
    },
    "order": {
      "id": "ord_M4tQx8ZkP1vRn6WcLs2B",
      "mode": "live",
      "number": 1186,
      "displayNumber": "#1186",
      "source": "api",
      "checkoutId": null,
      "store": null,
      "status": "paid",
      "needsReview": false,
      "fulfillment": "none",
      "fulfilledAt": null,
      "customer": {
        "id": "cus_Q9wLm3TzX7bKc2VnR5Hd",
        "email": "buyer@example.com",
        "name": null,
        "erased": false
      },
      "summary": "Pro Membership",
      "lineCount": 1,
      "unitCount": 1,
      "currency": "USD",
      "subtotal": "49.00",
      "discount": "0.00",
      "discountCode": null,
      "shipping": "0.00",
      "shippingMethod": null,
      "tax": "0.00",
      "total": "49.00",
      "amountPaid": "49.00",
      "received": "46.341750",
      "shipTo": null,
      "billTo": null,
      "reference": "order_1042",
      "paymentId": "pay_7AAiYH0Ykt11ED4hmfiN",
      "attemptCount": 1,
      "createdAt": 1790380525000,
      "updatedAt": 1790381342000,
      "paidAt": 1790381342000,
      "items": [
        {
          "name": "Pro Membership",
          "variant": null,
          "sku": null,
          "quantity": 1,
          "unitAmount": "49.00",
          "amount": "49.00"
        }
      ]
    },
    "customer": {
      "id": "cus_Q9wLm3TzX7bKc2VnR5Hd",
      "mode": "live",
      "email": "buyer@example.com",
      "name": null,
      "phone": null,
      "location": null,
      "shipTo": null,
      "billTo": null,
      "storeAccounts": [],
      "firstSeenAt": 1790380525000,
      "erased": false
    }
  }
}
```

| 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` |
| `data.order` | object or null | The payment's [order](#the-order-object), with its items, as this event leaves it. `null` for `ping` |
| `data.customer` | object or null | The order's [customer](#the-customer-object): contact details and addresses. `null` for `ping`, and until the buyer has given an email |

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

The order's `status` already includes the event: on `payment.paid` it's `paid`. It follows all of the order's payments, so an event about one attempt can carry an order that another attempt has already paid, such as a `payment.expired` for a buyer's abandoned first try. Decide what to do from the event's `type` and the payment; use the order for what to deliver and where.

## 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. A buyer who entered their email only now appears here first: `data.customer` and `data.order.customer` are set from it.

Example: 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.

Example: 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 reached a terminal failure. Today this is sent in **Test mode only**, for an explicit API simulation with `outcome: "failed"`. The Test provider's declined-card action keeps the payment open and sends no failed event. Live card providers don't report declines, so a live payment the buyer never completes ends as [`payment.expired`](#payment-expired). After a terminal failure, 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`, `data.order` and `data.customer` are `null`. Use it to check the address, the secret and your signature code.

Example: ping

```json
{
  "id": "evt_T2rVx9KcQm4NwLb7Ez0P",
  "type": "ping",
  "created": 1790380800,
  "livemode": false,
  "data": { "payment": null, "order": null, "customer": 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` |
| A second try at a store order | `payment.started` → `payment.expired` for the first session, then `payment.started` → `payment.paid` for the second. Every event carries the same `data.order.id` |

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. When you ship goods, deliver each order once, keyed on `data.order.id`: a buyer who pays two sessions for one order sends two `payment.paid` events for it.

## The payment object

Webhook payloads carry the payment, order and customer 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.com/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.com/docs/how-it-works.md#statuses) |
| `provider` | string or null | The provider the buyer chose, such as `stripe`. Null when the buyer paid in crypto |
| `providerName` | string or null | Its display name, such as `Stripe`, or the coin and network for crypto, such as `USDC on Base` |
| `method` | string | `card`, or `crypto` when the buyer sent stablecoins from their own wallet |
| `network` | string or null | Crypto payments: the network the money arrived on, such as `base`. Null for card |
| `depositAddress` | string or null | The one-time address this payment is paid into: on Polygon for card, set when a Live payment starts; on `network` for crypto |
| `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 |
| `orderId` | string or null | The [order](#the-order-object) this payment is an attempt at, `ord_…` |

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

## The order object

`data.order` is the [order](https://railbed.com/docs/api/orders.md) the payment belongs to, with its items. It has the API order's facts in camelCase, plus a few the dashboard uses.

| Field | Type | Description |
|---|---|---|
| `id` | string | The order's id, `ord_…`. The same id the API uses |
| `mode` | string | `live` or `test` |
| `number` | integer | Railbed's order number, counting from 1001 in each mode |
| `displayNumber` | string | The number to show people, such as `#1042`: your store's number when you sent one, otherwise Railbed's |
| `source` | string | Where its first payment came from: `api`, `checkout` or `payment_link` |
| `checkoutId` | string or null | The checkout it came from, `chk_…` |
| `store` | object or null | For orders sent with `order.id`: `platform`, `id`, `name`, `url`, `orderId`, `orderNumber`, `orderUrl` and `customerId` (the `customer.id` you sent) |
| `status` | string | The order's [status](https://railbed.com/docs/api/orders.md#status), including this event |
| `needsReview` | boolean | `true` when it's `held` and needs you: a held payment can be accepted as paid in the dashboard, or what's paid doesn't cover `total` |
| `fulfillment` | string | `none`, `unfulfilled` or `fulfilled`. See [fulfilment](https://railbed.com/docs/api/orders.md#fulfilment) |
| `fulfilledAt` | integer or null | When it was marked `fulfilled`, in ms |
| `customer` | object or null | The customer at a glance: `id`, `email`, `name` and `erased` |
| `summary` | string | The first item and how many more lines, such as `Magnesium glycinate · 120 capsules +2` |
| `lineCount` | integer | How many item lines |
| `unitCount` | integer | How many units across all lines |
| `currency` | string | The currency of every amount on the order |
| `subtotal` | string | The items' `amount`s added up |
| `discount` | string | Taken off the subtotal |
| `discountCode` | string or null | The code the buyer used |
| `shipping` | string | Shipping charged |
| `shippingMethod` | string or null | How it ships |
| `tax` | string | Tax charged |
| `total` | string | `subtotal` − `discount` + `shipping` + `tax` |
| `amountPaid` | string | What its paid payments in `currency` add up to. Below `total` when a part-payment left it `held` |
| `received` | string or null | USDC that reached your wallet from its paid payments, with six decimal places like the payment's `merchantReceived`. `null` until it's reported |
| `shipTo` | object or null | The shipping address: `name`, `line1`, `line2`, `city`, `region`, `postalCode`, `country` |
| `billTo` | object or null | The billing address, in the same shape |
| `reference` | string or null | Your `reference` from its latest payment |
| `paymentId` | string or null | The payment that decides its status: the paid one, else the held one, else the latest |
| `attemptCount` | integer | How many payments it has |
| `createdAt` | integer | When the order was created, in ms |
| `updatedAt` | integer | When it last changed, in ms |
| `paidAt` | integer or null | When its first payment became `paid`, in ms |
| `items` | array | Each line's `name`, `variant`, `sku`, `quantity`, `unitAmount` (or `null`) and `amount`, as on the [API's order](https://railbed.com/docs/api/orders.md#items). The first 50 lines; `lineCount` says how many there are, and the [API's order](https://railbed.com/docs/api/orders.md#retrieve-an-order) has them all |

For every payment attempt's id, [read the order](https://railbed.com/docs/api/orders.md#retrieve-an-order): the API's order lists them in `payment_ids`. A store order's `data.order` looks like this in part:

Example: data\.order for a store order (trimmed)

```json
{
  "id": "ord_Vd3kR8mQ1xTn6LpZs0Wa",
  "displayNumber": "#1042",
  "store": {
    "platform": "woocommerce",
    "id": "3f6c1a2e-8b4d-4e9a-9c7f-2d5b8e1a4c60",
    "name": "yourstore.com",
    "url": "https://yourstore.com/",
    "orderId": "1042",
    "orderNumber": "1042",
    "orderUrl": "https://yourstore.com/wp-admin/post.php?post=1042&action=edit",
    "customerId": "88"
  },
  "status": "paid",
  "fulfillment": "unfulfilled",
  "total": "135.31",
  "shipTo": {
    "name": "Maya Okafor",
    "line1": "418 Linden Avenue",
    "line2": "Apt 3B",
    "city": "Portland",
    "region": "OR",
    "postalCode": "97214",
    "country": "US"
  },
  "items": [
    {
      "name": "Magnesium glycinate",
      "variant": "120 capsules",
      "sku": "HN-MG-120",
      "quantity": 2,
      "unitAmount": "32.00",
      "amount": "64.00"
    }
  ]
}
```

## The customer object

`data.customer` is the order's [customer](https://railbed.com/docs/api/customers.md), with their contact details as they were when the event happened. It's `null` until the buyer has given an email.

| Field | Type | Description |
|---|---|---|
| `id` | string | The customer's id, `cus_…`. The same id the API uses |
| `mode` | string | `live` or `test` |
| `email` | string or null | Their email, lowercased. `null` once you've deleted their details |
| `name` | string or null | Their name |
| `phone` | string or null | Their phone number |
| `location` | string or null | Where they are, such as `Portland, OR, US`, from the shipping address or else the billing address |
| `shipTo` | object or null | Their latest shipping address, in the order's address shape |
| `billTo` | object or null | Their latest billing address |
| `storeAccounts` | array | Their customer ids in your stores: `platform`, `storeId`, `storeName` and `customerId` for each |
| `firstSeenAt` | integer | When they first appeared, in ms |
| `erased` | boolean | `true` once you've deleted their details |

Their order counts, what they've spent and your private note are never sent in webhooks. For counts and totals, [read the customer](https://railbed.com/docs/api/customers.md#retrieve-a-customer).

When you [delete a customer's details](https://railbed.com/docs/api/customers.md#deleted-customers), they're also removed from the bodies kept in your delivery log: a resend carries `null` in their place. Events already delivered can't be recalled.
