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 otherorder.updatedevents,media_key.rotatedandmerchant.plan_changedare 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 →