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.createdAn order is madeThe order
order.updatedAn order's status changes: paid, cancelled, expired, refunded, shippedThe order
invoice.created, invoice.paid, invoice.expiredA bitcoin invoice is made, paid, or runs out unpaidThe invoice
payment.receivedA payment is recorded: bitcoin, card, cash, a bank transferThe payment, with order_id and invoice_id
payment.confirmedA payment is final (a Lightning one at once, an on-chain one at its confirmations)The payment
payment.flaggedA payment came in for an order that was closed or paid already, and is to be refundedThe payment, with flag_reason
payment.disputedA card payment is disputed at its processorThe payment
refund.createdA refund is recorded, made at once or waiting for approvalThe refund (below)
refund.updatedA refund's status changes: made (completed), cancelled, failedThe refund
invoice.payment_detected, onchain_payment.held, onchain_payment.reversedOn-chain: a payment seen, one held for your decision, one that left the chainThe invoice or the on-chain payment
subscription.*A customer subscription is created, activated, paused, resumed, cancelled, renewed, falls past due, or its trial ends soonThe subscription; renewed and past_due add the period's order
media_key.rotatedA PrivaPaid media key is rotatedThe key, never its material
merchant.plan_changedYour plan changesYour 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-Signaturet=<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-TimestampWhen it was sent, in unix seconds (the same t)
X-Webhook-EventThe event
X-Webhook-Delivery-ID, X-Idempotency-KeyThe delivery, the same on every attempt
X-Webhook-SignatureDeprecated: 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 →