Webhooks
Get a signed HTTP call to your own systems the moment something happens in your store — a new order, an answered COD confirmation, an abandoned checkout or a refund.
Webhooks push events out of IndiSell to a URL you control, so your own tools react in real time — a 3PL that holds risky orders, a Google Sheet of new sales, a WhatsApp flow for abandoned carts, an ERP that books refunds. You give us an HTTPS endpoint and pick an event; we POST a JSON payload every time it happens, and every call is signed so you can prove it came from us.
Set them up in your dashboard under Settings → Webhooks.
Add an endpoint
- Go to Settings → Webhooks and click Add webhook.
- Enter your endpoint URL (must be
https://and publicly reachable — private and loopback hosts are rejected). - Choose the event to subscribe to. One endpoint receives one event; add several endpoints to receive several events (the same URL can be used more than once).
- Click Add webhook. We show the signing secret once — copy it now and store it somewhere safe. It's never shown again; if you lose it, rotate it (which invalidates the old one).
Each endpoint has an on/off switch, a health badge (Healthy / Failing / Paused) and a log of its recent deliveries with status, HTTP code and round-trip time.
Events
Event (topic) | Fires when | Key fields in data | |
|---|---|---|---|
order_created | An order is placed through the IndiSell checkout. | orderName, orderId, amount, currency, type, paymentType, financialStatus, placedAt, itemCount, items[], discountCode, shippingAddress, customer{}, source{} | |
cod_confirmed | A shopper confirms or cancels a COD order before dispatch. | order, orderId, action (confirmed \ | cancelled), amount, currency, type, customer{}, respondedAt |
abandoned_checkout | A shopper verified their phone, then left without ordering — see the timing note below. | session, resumeUrl, lastActivityAt, customer{}, cart{ itemCount, total, currency, items[] }, source{} | |
refund_created | A refund is issued for an order (full or partial). | orderName, transaction, gateway, amount, totalRefunded, orderAmount, full, currency, customer{}, refundedAt |
cod_confirmed fires for both outcomes — read data.action to tell a confirmation from a cancellation, so a 3PL hold or a courier booking reacts to the right one.abandoned_checkout fires. Once per checkout session, when it's been idle for ~30 minutes after the shopper verified their phone but placed no order — a checkout is counted abandoned the same way whether or not you run recovery emails. It fires independently of IndiSell's cart-recovery feature: you get the event even with recovery switched off, and it ignores recovery's quiet hours and marketing opt-outs (it's your integration reacting to the event, not a promotional message). It never fires for a cart that was later paid — including a prepaid/UPI order that completed asynchronously. Delivery rides a background sweep, so expect it within a few minutes of the 30-minute mark.The customer object
Every event carries a customer block so you never need a second lookup to know who the shopper is:
"customer": {
"firstName": "Aarav",
"lastName": "Sharma",
"name": "Aarav Sharma",
"email": "[email protected]",
"phone": "9876543210"
}
Any field the shopper hasn't given yet is simply absent (a guest who only entered a phone has phone and nothing else). Amounts are whole rupees; source carries the traffic attribution (source, medium, campaign, gclid, fbclid) when it was captured at landing.
What a delivery looks like
Every delivery is a POST with a JSON body. The body is always the same envelope — the event name, the API version, a timestamp, and the event-specific data:
{
"topic": "order_created",
"apiVersion": "2026-05",
"createdAt": "2026-09-02T18:30:00.000Z",
"data": {
"orderName": "#1024",
"orderId": "gid://shopify/Order/55012...",
"amount": 1968,
"currency": "INR",
"type": "prepaid",
"paymentType": "prepaid",
"financialStatus": "paid",
"placedAt": "2026-09-02T18:29:58.000Z",
"itemCount": 2,
"items": [
{ "title": "Vitamin C Serum", "qty": 1, "price": 1299, "variantId": 4412345 },
{ "title": "Face Wash", "qty": 1, "price": 669, "variantId": 4498765 }
],
"discountCode": "DIWALI10",
"shippingAddress": {
"name": "Aarav Sharma", "address1": "12 MG Road", "city": "Pune",
"province": "Maharashtra", "zip": "411001", "country": "India", "phone": "9876543210"
},
"customer": { "firstName": "Aarav", "lastName": "Sharma", "name": "Aarav Sharma", "email": "[email protected]", "phone": "9876543210" },
"source": { "source": "google", "medium": "cpc", "campaign": "diwali" }
}
}
Payment method. Read paymentType (or the identical type) — its value is cod, partial_cod, or prepaid. IndiSell does not send Shopify's raw payment_gateway_names, so a COD order reads "paymentType": "cod", never "manual". (Shopify itself labels a pending COD order's gateway manual — that comes from Shopify, not this webhook.)
These headers ride every request:
| Header | Value |
|---|---|
Content-Type | application/json |
X-IndiSell-Topic | the event, e.g. order_created |
X-IndiSell-Timestamp | Unix time (seconds) the request was signed |
X-IndiSell-Signature | hex HMAC-SHA256 — see below |
User-Agent | IndiSell-Webhooks/1.0 |
More example payloads
{
"topic": "cod_confirmed",
"apiVersion": "2026-05",
"createdAt": "2026-09-02T10:15:00.000Z",
"data": {
"order": "#1024",
"orderId": "gid://shopify/Order/55012...",
"action": "confirmed",
"amount": 1968,
"currency": "INR",
"type": "cod",
"customer": { "firstName": "Aarav", "name": "Aarav Sharma", "email": "[email protected]", "phone": "9876543210" },
"respondedAt": "2026-09-02T10:14:59.000Z"
}
}
{
"topic": "abandoned_checkout",
"apiVersion": "2026-05",
"createdAt": "2026-09-02T09:00:00.000Z",
"data": {
"session": "cpk_9f2a...",
"resumeUrl": "https://yourstore.com/cart?cpk=...",
"lastActivityAt": "2026-09-02T08:52:00.000Z",
"customer": { "firstName": "Aarav", "name": "Aarav Sharma", "email": "[email protected]", "phone": "9876543210" },
"cart": {
"itemCount": 3,
"total": 2297,
"currency": "INR",
"items": [
{ "title": "Cotton Kurta — Blue", "qty": 1, "price": 1299, "variantId": 4412345, "image": "https://cdn.shopify.com/..." },
{ "title": "Silk Scarf", "qty": 2, "price": 499, "variantId": 4498765 }
]
},
"source": { "source": "google", "medium": "cpc", "campaign": "diwali", "gclid": "Cj0K..." }
}
}
{
"topic": "refund_created",
"apiVersion": "2026-05",
"createdAt": "2026-09-02T11:00:00.000Z",
"data": {
"orderName": "#1024",
"transaction": "txn_abc123",
"gateway": "cashfree",
"amount": 500,
"totalRefunded": 500,
"orderAmount": 1968,
"full": false,
"currency": "INR",
"via": "dashboard",
"customer": { "name": "Aarav Sharma", "email": "[email protected]", "phone": "9876543210" },
"refundedAt": "2026-09-02T11:00:00.000Z"
}
}
Verify the signature
The signature proves the call came from IndiSell and wasn't edited or replayed. It's an HMAC-SHA256, keyed by that endpoint's secret, over the string ` ${timestamp}.${rawBody} ` — the exact bytes we sent, before any JSON parsing.
Verify it before you trust the payload:
import crypto from "crypto";
// `rawBody` must be the exact request body bytes — read it before body-parsing.
function verifyIndisell(headers, rawBody, secret) {
const ts = headers["x-indisell-timestamp"];
const sig = headers["x-indisell-signature"];
// Reject anything older than 5 minutes — kills replays.
if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const want = crypto.createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
const a = Buffer.from(want), b = Buffer.from(String(sig || ""));
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
JSON.stringify(parsedBody) can reorder keys or change spacing, and the signature won't match. Read the raw bytes first, verify, then parse.Retries and acknowledgement
- Reply
2xxas soon as you've accepted the event. Any other status (or no response) counts as a failure. - We try up to 3 times. Network errors,
5xxand429are retried with a short backoff; other4xxresponses are treated as a deliberate rejection and are not retried (so a bad payload doesn't get hammered at your endpoint). - Each attempt times out after 8 seconds — do slow work after you've replied, never before.
- A retried delivery can arrive more than once. Make your handler idempotent — de-duplicate on the order reference, or on
topic+X-IndiSell-Timestamp.
Test, rotate, pause, remove
- Send test posts a sample payload (
data: { "sample": true, "message": "Test delivery from IndiSell" }) to an existing endpoint, so you can wire up and confirm receipt before anything real fires. - Rotate secret issues a new secret and invalidates the old one — shown once, like on create.
- Pause stops deliveries without deleting the endpoint; Remove deletes it. An endpoint that fails repeatedly is flagged Failing so you can spot a broken receiver at a glance.