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. |
New field: customer_country (2026-09-30)
order.* and checkout.session.* payloads now include customer_country — the ISO 3166-1 alpha-2 country the buyer selected at checkout, or null when unknown. This is additive: nothing else in the payload changed and signatures are verified exactly as before. Ignore fields you don't use.New fields: line_items and discount (2026-10-07)
order.* payloads now include line_items (always at least one line, with order_bump: true on add-ons the buyer ticked at checkout) and discount (null, or the coupon code and amount off). Amounts are already discounted, and line totals add up to amount_total. If you fulfil from webhooks, deliver every line so order bumps are not missed. This is additive.Upsells: parent_order_id and checkout_session_id (2026-10-07)
order.paid has parent_order_id, the order it was sold after (null on every other order). upsell.* payloads include checkout_session_id, the API session the purchase started at (null otherwise). API sessions show upsells only with allow_upsells: true.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.Test-mode payments send order.paid, order.payment_failed and invoice.paid exactly like live ones, so you can build and test your handler before going live. payout.* events fire for every payout, including Payout Now.
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.
Events that happen while an endpoint is disabled are not lost: they are recorded as failed deliveries. After re-enabling, choose Resend failed deliveries on the endpoint to queue every failed delivery from the last 7 days again.
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 rely on delivery order. Retries and resends can deliver events out of sequence — for example
order.refundedbefore theorder.paidit follows. Compare the envelope'screatedtimestamp, or fetch the current state from the API, instead of assuming the latest delivery is the latest state. - 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.
