DocsAPI ReferenceWebhooks
Webhooks
Webhooks tell your server the moment something happens — a checkout is paid, a subscription renews, a refund is processed. They are the reliable way to trigger fulfillment.
Setting up an endpoint
- In the dashboard, open Account → Webhooks and add your endpoint URL (must be HTTPS in live mode).
- Choose which event types the endpoint should receive.
- Copy the endpoint's signing secret and store it in your server environment (e.g.
SALEONIX_WEBHOOK_SECRET). You can reveal it again or roll it from the dashboard at any time.
Secret rotation without downtime
v1 entries, and verifying against your current secret keeps working as long as any one entry matches.Event types
| Event | When it fires |
|---|---|
checkout.session.created | A checkout session was created (payment not yet attempted). |
checkout.session.completed | A session was paid. Fulfill on this. |
checkout.session.payment_failed | A payment attempt was declined — the session is terminal. |
checkout.session.expired | A session expired before it was paid. |
order.paid | An order settled — funds captured at the gateway. |
order.payment_failed | An order's payment attempt was terminally declined. |
order.expired | An abandoned pending order expired without any charge. |
order.refunded | An order was refunded in full. |
order.partially_refunded | An order was refunded in part; a refundable remainder is left. |
refund.created | A refund was accepted by the gateway and money moved back. |
refund.failed | A refund attempt was declined. No money moved. |
subscription.created | A subscription was created. |
subscription.updated | Status, next billing date, or amount changed. |
subscription.payment_succeeded | A recurring charge succeeded. |
subscription.payment_failed | A recurring charge was declined. |
subscription.past_due | A subscription entered past-due after a failed charge. |
subscription.canceled | Canceled by the merchant or after exhausted retries. |
subscription.expired | The final scheduled payment was reached. |
invoice.created | An invoice was issued to a customer. |
invoice.paid | An invoice was paid in full. |
invoice.payment_failed | A payment against an invoice was declined. |
invoice.voided | An invoice was voided and is no longer payable. |
payout.created | A payout to your bank account was created. |
payout.updated | A payout moved to a non-terminal state (e.g. in transit). |
payout.paid | A payout settled in your bank account. |
payout.failed | A payout failed; funds return to your available balance. |
payout.canceled | A payout was canceled before leaving the platform. |
Refunds emit two events
refund.created (the refund itself) and then either order.refunded or order.partially_refunded(the order's new state). Reconcile on whichever matches your model — do not count both as money movement.webhook.test is sent by the Send test event button in your dashboard. It is never produced by real store activity, so a handler that switches on the types above will ignore it safely.
The event envelope
Every delivery is a JSON POST with the same Stripe-style envelope. Retries always send the identical body:
POST — your endpoint
{
"id": "evt_...",
"object": "event",
"type": "checkout.session.completed",
"livemode": false,
"created": 1780000000,
"data": {
"object": {
"id": "cs_test_aBcD...",
"object": "checkout.session",
"status": "complete",
"payment_status": "paid"
// … the full resource
}
}
}Verifying signatures
Each request carries a Saleonix-Signature header:
Header
Saleonix-Signature: t=1780000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdThe signature is HMAC-SHA256(secret, `{t}.{rawBody}`). Because the timestamp is signed, captured requests can't be replayed outside the 5-minute tolerance window. Always verify against the raw request body — parsing and re-serializing JSON will break the signature.
Verify a webhook delivery
const crypto = require("crypto");
const TOLERANCE_SECONDS = 5 * 60;
function verifyWebhookSignature(rawBody, header, secret) {
let timestamp = null;
const signatures = [];
for (const part of header.split(",")) {
const [key, value] = part.split("=", 2).map((s) => (s ?? "").trim());
if (key === "t" && /^\d+$/.test(value)) timestamp = Number(value);
if (key === "v1" && /^[0-9a-f]{64}$/.test(value)) signatures.push(value);
}
if (timestamp == null || signatures.length === 0) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
return signatures.some((sig) => {
const a = Buffer.from(sig, "hex");
const b = Buffer.from(expected, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
}
// Express example — note: you need the RAW body, not parsed JSON.
app.post("/webhooks/saleonix", express.raw({ type: "application/json" }), (req, res) => {
const header = req.headers["saleonix-signature"] || "";
if (!verifyWebhookSignature(req.body.toString("utf8"), header, process.env.SALEONIX_WEBHOOK_SECRET)) {
return res.status(400).send("invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
switch (event.type) {
case "checkout.session.completed":
// ✅ fulfill the order for event.data.object
break;
case "subscription.past_due":
// ⚠️ notify the customer
break;
}
res.status(200).send("ok"); // acknowledge fast; do slow work async
});Delivery, retries and headers
A delivery succeeds on any 2xx. Anything else — including a timeout — is retried. You have 10 seconds to respond before we give up on an attempt.
| Attempt | Sent after |
|---|---|
| 1 | Immediately (within a minute of the event) |
| 2 | ~1 minute later |
| 3 | ~5 minutes later |
| 4 | ~30 minutes later |
| 5 | ~2 hours later |
| 6 | ~10 hours later |
Each delay carries ±15% jitter. After the 6th failed attempt the delivery is marked failed and stops. You can re-send it by hand from Recent events in your dashboard. An endpoint that accumulates 20 consecutive failed deliveries is automatically disabled — re-enable it there once your handler is fixed.
Alongside the signature, every request carries:
| Header | Meaning |
|---|---|
Saleonix-Event-Id | Stable event id. Use it as your idempotency key — it is identical across retries. |
Saleonix-Event-Type | The event type, so you can route before parsing. |
Saleonix-Delivery-Id | This endpoint's delivery record. Differs per endpoint. |
Saleonix-Delivery-Attempt | 1-based attempt counter. |
Best practices
- Return 200 quickly. Acknowledge the delivery, then do slow work (emails, provisioning) asynchronously. Non-2xx responses are retried.
- Be idempotent. Store processed event
ids and skip duplicates — retries and redeliveries can send the same event more than once. - Don't trust the payload alone for money decisions. For fulfillment you can additionally retrieve the session with your secret key as a second check.
- Check
livemodeso test events never trigger production fulfillment.