SatLane
Documentation

Receive webhooks

Receive webhooks

We POST signed JSON to your callback_url (per-invoice) or to webhook endpoints configured on the store.

Success: any 2xx response. We mark delivery success and stop.

Retries (5xx + network errors / timeouts): initial attempt followed by retries at 1m → 5m → 30m → 2h → 12h → 24h. That's 7 total attempts before the delivery transitions to dead_letter (replayable from the dashboard).

Permanent failures (4xx): treated as a vendor-side bug and not retried. Delivery moves straight to failed so we don't hammer a broken endpoint for 24 hours. Common causes: wrong URL, expired auth on your reverse proxy, signature verification raising on a legitimate event. Check the delivery's response_body in your dashboard.

Timeout: 10 seconds per attempt. Make your handler write the side effect (mark order paid in your DB) and respond 200 quickly. If you must do slow work, ack first and do the slow work in a queue.

Headers

POST /your-handler HTTP/1.1
Content-Type: application/json
User-Agent: SatLane-Webhook/1.0
X-SatLane-Signature: t=1721481600,v1=2a3b4c5d…
X-SatLane-Event-Id: evt_abc123
X-SatLane-Event-Type: invoice.paid

Body shape

{
  "event_id": "evt_abc123",
  "event_type": "invoice.paid",
  "created_at": "2026-05-16T11:25:00.000Z",
  "livemode": true,
  "data": {
    "invoice": { /* same shape as POST /v1/invoices response */ }
  }
}

Verify the signature

The signature header format is t=<timestamp>,v1=<hex>. The signed payload is ${timestamp}.${rawRequestBody} and the HMAC algorithm is SHA-256 with your endpoint secret as the key.

Node (using our published helper):

import { verifySignature } from '@satlane/webhooks';

app.post('/webhooks/satlane', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.header('X-SatLane-Signature');
  try {
    verifySignature(req.body, sig, { secrets: [process.env.SATLANE_WEBHOOK_SECRET] });
  } catch {
    return res.status(400).end();
  }
  const event = JSON.parse(req.body);
  // safe to act on event.data.invoice...
  res.status(200).end();
});

Python:

import hmac, hashlib, time

def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300):
    parts = dict(p.split('=', 1) for p in header.split(','))
    t, v1 = int(parts['t']), parts['v1']
    if abs(time.time() - t) > tolerance:
        raise ValueError('timestamp out of tolerance')
    signed = f'{t}.{raw_body.decode()}'.encode()
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, v1):
        raise ValueError('signature mismatch')

PHP:

function verifySatlaneSignature(string $rawBody, string $header, string $secret, int $tolerance = 300): bool {
    $parts = [];
    foreach (explode(',', $header) as $p) {
        [$k, $v] = explode('=', $p, 2);
        $parts[$k] = $v;
    }
    $t = (int) $parts['t']; $v1 = $parts['v1'];
    if (abs(time() - $t) > $tolerance) return false;
    $expected = hash_hmac('sha256', "{$t}.{$rawBody}", $secret);
    return hash_equals($expected, $v1);
}

Vendor SDKs should reject events with timestamps older than 5 minutes (replay protection). During secret rotation we keep the old secret valid for 24 hours — your verifier can pass both to secrets: [current, previous].