IndiSell Docs Dashboard →
Developers

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.

ℹ️
Webhooks are outbound only — IndiSell calling you. They're separate from the Shopify webhooks IndiSell receives on your behalf; you don't manage those.

Add an endpoint

  1. Go to Settings → Webhooks and click Add webhook.
  2. Enter your endpoint URL (must be https:// and publicly reachable — private and loopback hosts are rejected).
  3. 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).
  4. 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 whenKey fields in data
order_createdAn order is placed through the IndiSell checkout.orderName, orderId, amount, currency, type, paymentType, financialStatus, placedAt, itemCount, items[], discountCode, shippingAddress, customer{}, source{}
cod_confirmedA shopper confirms or cancels a COD order before dispatch.order, orderId, action (confirmed \cancelled), amount, currency, type, customer{}, respondedAt
abandoned_checkoutA shopper verified their phone, then left without ordering — see the timing note below.session, resumeUrl, lastActivityAt, customer{}, cart{ itemCount, total, currency, items[] }, source{}
refund_createdA 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.
ℹ️
When 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:

HeaderValue
Content-Typeapplication/json
X-IndiSell-Topicthe event, e.g. order_created
X-IndiSell-TimestampUnix time (seconds) the request was signed
X-IndiSell-Signaturehex HMAC-SHA256 — see below
User-AgentIndiSell-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);
}
⚠️
Sign over the raw body, not a re-serialized object. 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 2xx as 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, 5xx and 429 are retried with a short backoff; other 4xx responses 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.
ℹ️
Keep your endpoint fast and idempotent, verify every signature, and serve it over HTTPS. That's the whole contract — no IP allowlist to maintain, no polling.