# Webhooks

> Receive SatsRail events at your server: the events, the payload, verifying the signature with its timestamp, retries and idempotency, test and live mode, and an endpoint switched off.

Source: https://www.satsrail.com/developers/guides/webhooks/

Events about your payments, orders and refunds, posted to your server, signed.

## Endpoints

An endpoint is an HTTPS URL on your server and the events it subscribes to. Create one on the Webhooks page of your dashboard, or with `POST /api/v1/m/webhooks`:

```
curl -X POST https://app.satsrail.com/api/v1/m/webhooks \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"webhook": {"url": "https://example.com/webhooks/satsrail",
                   "events": ["payment.received", "refund.created"]}}'
```

The answer carries the endpoint's `secret_key` (`whsec_...`) once: keep it, it is what you verify each delivery with. `GET /api/v1/m/webhooks` lists your endpoints and every event one can subscribe to (`available_events`).

## Test and live mode

An endpoint made with a test key (`sk_test_`) is a test endpoint, one made with a live key a live endpoint. Every event goes only to the endpoints of its own mode: a test order's events to test endpoints, a live order's to live ones. Events about your account itself (`merchant.plan_changed`), customer subscriptions and on-chain payments are live only.

## Events

| Event | When | `data` |
| --- | --- | --- |
| `order.created` | An order is made | The order |
| `order.updated` | An order's status changes: paid, cancelled, expired, refunded, shipped | The order |
| `invoice.created`, `invoice.paid`, `invoice.expired` | A bitcoin invoice is made, paid, or runs out unpaid | The invoice |
| `payment.received` | A payment is recorded: bitcoin, card, cash, a bank transfer | The payment, with `order_id` and `invoice_id` |
| `payment.confirmed` | A payment is final (a Lightning one at once, an on-chain one at its confirmations) | The payment |
| `payment.flagged` | A payment came in for an order that was closed or paid already, and is to be refunded | The payment, with `flag_reason` |
| `payment.disputed` | A card payment is disputed at its processor | The payment |
| `refund.created` | A refund is recorded, made at once or waiting for approval | The refund (below) |
| `refund.updated` | A refund's status changes: made (`completed`), `cancelled`, `failed` | The refund |
| `invoice.payment_detected`, `onchain_payment.held`, `onchain_payment.reversed` | On-chain: a payment seen, one held for your decision, one that left the chain | The invoice or the on-chain payment |
| `subscription.*` | A customer subscription is created, activated, paused, resumed, cancelled, renewed, falls past due, or its trial ends soon | The subscription; `renewed` and `past_due` add the period's order |
| `media_key.rotated` | A PrivaPaid media key is rotated | The key, never its material |
| `merchant.plan_changed` | Your plan changes | Your account and plan |

## The payload

Each delivery is a `POST` with a JSON body:

```
{
  "id": "4f0c7b8e-1d2a-4c55-9a77-2f3e1b6d9c10",
  "event": "refund.created",
  "created_at": "2026-11-07T18:02:30Z",
  "data": {
    "id": "b3c4d5e6-...",
    "status": "completed",
    "kind": "refund",
    "refund_method": "cash",
    "amount_cents": 1299,
    "currency": "usd",
    "order_id": "e2a9c072-...",
    "order_number": "ORD-C1A6B85074F5890B",
    "sale_id": "e2a9c072-...",
    "refund_sale_id": "b3c4d5e6-...",
    "payment_id": "120be4e9-...",
    "location_id": "92f0...",
    "line_items": [{ "sale_item_id": "7a1e...", "qty": 1 }],
    "refunded_at": "2026-11-07T18:02:30Z",
    "made_at": "2026-11-07T18:02:30Z",
    "created_at": "2026-11-07T18:02:30Z"
  }
}
```

A refund's `order_id` is null once its order has been deleted, six months after its sale; `order_number` and `sale_id` still name what it refunds. Amounts are in the currency's minor unit. A refund's reason and notes are never sent.

## Verifying a delivery

Each delivery carries these headers:

| `X-SatsRail-Signature` | `t=<unix seconds>,v1=<hex>`: the HMAC-SHA256, keyed with your endpoint's secret, of the timestamp, a full stop and the raw body |
| --- | --- |
| `X-Webhook-Timestamp` | When it was sent, in unix seconds (the same `t`) |
| `X-Webhook-Event` | The event |
| `X-Webhook-Delivery-ID`, `X-Idempotency-Key` | The delivery, the same on every attempt |
| `X-Webhook-Signature` | Deprecated: the HMAC-SHA256 of the body alone, kept while integrations move to `X-SatsRail-Signature` |

To verify one: split `X-SatsRail-Signature` into `t` and `v1`, compute the HMAC-SHA256 of `t + "." + raw body` with your secret, compare it with `v1` in constant time, and refuse the delivery when `t` is more than five minutes from your clock. Sign the body exactly as it arrived, before any JSON parsing. The timestamp is what keeps a recorded delivery from being replayed later: the old `X-Webhook-Signature` covers the body alone, so a delivery signed only by it can be sent again at any time.

###### Ruby

```
require "openssl"

def satsrail_delivery?(secret, header, raw_body, tolerance: 300)
  parts = header.to_s.split(",").to_h { |part| part.strip.split("=", 2) }
  t = Integer(parts["t"], exception: false)
  return false unless t && parts["v1"] && (Time.now.to_i - t).abs <= tolerance

  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}.#{raw_body}")
  OpenSSL.fixed_length_secure_compare(expected, parts["v1"])
end
```

###### Node.js

```
const crypto = require("crypto");

function satsrailDelivery(secret, header, rawBody, tolerance = 300) {
  const parts = Object.fromEntries(String(header).split(",").map((p) => p.trim().split("=", 2)));
  const t = Number.parseInt(parts.t, 10);
  if (!t || !parts.v1 || Math.abs(Date.now() / 1000 - t) > tolerance) return false;

  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  return expected.length === parts.v1.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
```

## Answers, retries and duplicates

Answer with any 2xx status within ten seconds. Anything else, or no answer, is a failure, and the delivery is tried again with the same body, the same payload `id` and the same `X-Idempotency-Key`:

- Payment, invoice, refund, paid-order, on-chain and subscription events are kept until they are delivered: tried again 30 seconds after a failure, then twice as long each time, every hour at most.
- `order.created`, the other `order.updated` events, `media_key.rotated` and `merchant.plan_changed` are tried three times: at once, 30 seconds and 5 minutes later.

Delivery is at least once: a delivery your server took can still arrive again (a timeout after you answered, a retry from the dashboard). Store the payload `id` (or `X-Idempotency-Key`) in the same transaction as what you do with the event, and answer 2xx to one you have seen without doing it again.

## An endpoint switched off

You can switch an endpoint off and on from its page, or with `PATCH /api/v1/m/webhooks/:id` and `active`. SatsRail switches one off itself when ten of its deliveries in a row of the events tried three times failed every attempt within a day, none succeeding since: you are emailed, its page says so, and `disabled_at` on the webhook says when. Payment events never switch an endpoint off.

Nothing is lost while an endpoint is off: every event it subscribes to is kept for it, and sent once it is switched on again.

## The webhook endpoints API

Create, list, update and delete endpoints, and read their deliveries.

[API Reference →](https://www.satsrail.com/developers/)
