Grind Shop can tell other tools what happens in your store, and let them answer back. This page covers everything: which events exist and what each one contains, how to receive and check them, how Interakt (WhatsApp) is connected, what happens to customer consent, and what to do when a message was not sent.
Everything here is done on the Apps page of your admin. Only owners and admins can install apps, add keys or change them.
1. The three ways to connect
- Webhooks app. Sends the events you choose, as signed JSON, to up to five web addresses (called endpoints) that you add. Works with AiSensy, WATI, Pabbly, Zapier, Make, n8n, Google Sheets scripts or your own server.
- Interakt app. Talks to Interakt for you, so you can run WhatsApp campaigns and journeys without writing code. You only paste your Interakt key.
- Order replies. A private address your tool calls to confirm or cancel an order, for example when a customer taps Confirm or Cancel in a WhatsApp message on a cash on delivery order.
Each app shows exactly what it may see and do before you install it. Nothing is sent anywhere until you add an endpoint or a key.
2. Add a webhook endpoint
- Open Apps, choose Webhooks and press Install app.
- Under Endpoints, give the endpoint a name (only you see it), paste its https web address and tick the events it should receive. Press Add endpoint.
- Copy the signing key that appears. It starts with
whsec_. It is shown only once and is stored encrypted, so even we cannot show it again. If you lose it, press New signing key; the old key stops working at once. - Press Send test event. Your endpoint receives a harmless event of type
test.pingand the page tells you what it answered.
Rules for the address: it must start with https://, be reachable from the internet, use the normal port (443) and contain no user name or password. Addresses that point to private or internal networks (including localhost and raw IP addresses) are refused, and we do not follow redirects, so give the final address. Only the start of an address is shown again on the page, because some tools put a secret in the path.
If a checkout event is not sent, check the setting Send checkout events only for shoppers who agreed to messages. It is on by default (see section 9).
3. What a delivery looks like
Every delivery is an HTTP POST with a JSON body and these headers:
| Header | Value |
|---|---|
Content-Type | application/json; charset=utf-8 |
User-Agent | Starts with GrindShop-Webhooks/1 |
X-GrindShop-Event | The event type, for example order.created |
X-GrindShop-Delivery | The id of this delivery. The same on every retry of it |
X-GrindShop-Attempt | 1 for the first try, 2 for the second, and so on |
X-GrindShop-Timestamp | The time of signing, in Unix seconds |
X-GrindShop-Signature | v1= followed by the signature (see section 4) |
The body is always the same envelope:
apiVersion:2026-11-01. New fields may be added within a version; nothing is renamed or removed.id: the id of the event. The same on every retry and for every endpoint. Use it to ignore duplicates.type: the event type.createdAt: when it happened (ISO 8601).store:id,slug,nameandcurrencyof your store.marketingOptIn: what the shopper agreed to,{ "whatsapp": true, "email": false }, ornullfor events that are not about a shopper.data: the details of the event (section 6).
Money is always an integer number of paise (₹1,196.00 is 119600). Dates are ISO 8601. Phone numbers are in international format (+919876543210).
Your server should answer with any 2xx status within 8 seconds. Do the slow work after answering.
4. Check the signature
Anyone who learns your endpoint address can post to it. The signature proves a request really came from your store and was not changed on the way.
How it is made: HMAC-SHA256 with your signing key, over the timestamp, a dot, and the raw body exactly as received. The result is sent as v1= plus 64 hexadecimal characters. Check it in three steps:
- Read the raw body before any JSON parsing. A body that was parsed and written again no longer matches.
- Compute the signature yourself and compare it with a constant-time function.
- Refuse requests whose timestamp is more than 5 minutes away from your clock. This stops someone from replaying an old request.
The header may hold more than one v1= value (separated by a space or comma); accept the request if any one of them matches.
Node.js:
import { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody: the body exactly as received (Buffer or string). headers: lower-case names.
export function verifyGrindShop(rawBody, headers, secret, toleranceSeconds = 300) {
const timestamp = headers['x-grindshop-timestamp'] ?? '';
const signature = headers['x-grindshop-signature'] ?? '';
if (!/^[0-9]{9,12}$/.test(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest();
return signature.split(/[\s,]+/).some((part) => {
const match = /^v1=([0-9a-f]{64})$/i.exec(part);
if (!match) return false;
const given = Buffer.from(match[1], 'hex');
return given.length === expected.length && timingSafeEqual(given, expected);
});
}
Python:
import hashlib
import hmac
import re
import time
def verify_grindshop(raw_body: bytes, headers: dict, secret: str, tolerance: int = 300) -> bool:
timestamp = headers.get("X-GrindShop-Timestamp", "")
signature = headers.get("X-GrindShop-Signature", "")
if not re.fullmatch(r"[0-9]{9,12}", timestamp):
return False
if abs(time.time() - int(timestamp)) > tolerance:
return False
expected = hmac.new(
secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256
).hexdigest()
for part in re.split(r"[\s,]+", signature):
match = re.fullmatch(r"v1=([0-9a-fA-F]{64})", part)
if match and hmac.compare_digest(match.group(1).lower(), expected):
return True
return False
PHP:
<?php
function verify_grindshop(string $rawBody, array $headers, string $secret, int $tolerance = 300): bool {
$timestamp = $headers['X-GrindShop-Timestamp'] ?? '';
$signature = $headers['X-GrindShop-Signature'] ?? '';
if (!preg_match('/^[0-9]{9,12}$/', $timestamp)) return false;
if (abs(time() - (int) $timestamp) > $tolerance) return false;
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
foreach (preg_split('/[\s,]+/', $signature) as $part) {
if (preg_match('/^v1=([0-9a-fA-F]{64})$/', $part, $m) && hash_equals($expected, strtolower($m[1]))) {
return true;
}
}
return false;
}
// $rawBody = file_get_contents('php://input');
Using a no-code tool (Zapier, Make, Pabbly)? Most of them cannot check signatures. The address is unguessable, but keep it private and treat the endpoint as untrusted: confirm things in your store before acting on them.
5. Retries, duplicates and pausing
- Retries. If your endpoint does not answer
2xxin time, we try again, up to 8 times in total, waiting longer each time (about 10 seconds, then 20, 40, 80 seconds, 3, 5 and 11 minutes, with some randomness). We retry on timeouts, network errors,408,425,429and5xx. Other4xxanswers, and redirects, are treated as final: retrying would not help, so fix the cause and press Try again in the delivery log. - Duplicates. Delivery is _at least once_. If your server processed a request but the answer was lost, the next try carries the same
id. Keep the ids you have handled and ignore repeats. When you have several endpoints and only one failed, only that one is retried. - No ordering. Events can arrive out of order. Use the timestamps inside
data. - Pausing. An endpoint that fails 10 times in a row over more than 24 hours is paused automatically and the Apps page says so, with the reason. An answer of
410 Gonepauses it at once. Test events never count. Turn the endpoint on again once it works; that also clears its failure history. - Delivery log. The Apps page lists every event with its status, attempts, last error and what happened to each request. Filter by status or event, and press Try again on a failed delivery.
6. Events and their data
Each section shows the complete JSON your endpoint receives (the values are made up). Where an event carries an order, the example shows "order": "(the order, as in order.created)" to save space: it is the full order object of the first example.
| Event | When it is sent | Besides the envelope, data has |
|---|---|---|
order.created | A new order is placed | order |
order.paid | An order is paid (online, or you mark a COD order as paid) | order, paidAt |
order.confirmed | An order is confirmed | order, confirmedAt, confirmedBy |
order.shipped | An order is handed to a carrier | order, shippedAt, tracking |
order.delivered | An order is delivered | order, deliveredAt |
order.cancelled | An order is cancelled | order, cancelledAt, reason |
refund.created | A refund is issued | refund, order |
customer.created | A new customer is created | customer |
checkout.started | A shopper typed a mobile number at checkout | checkout |
checkout.abandoned | A checkout stayed idle for 60 minutes without an order | checkout, abandonedAt, idleMinutes, recoveryUrl |
Notes on the fields:
order:id,number(for example 1001),name(#1001),createdAt,status(pending,confirmed,fulfilled,deliveredorcancelled),currency,customer(id,name,email,phone,marketingConsent),shippingAddress(name,phone,line1,line2,city,state,pincode,country) ornull,lineItems,discountCode,totals,payment,trackingandstatusUrl.lineItems[]:productId,variantId,sku,title,variantTitle,quantity,unitPricePaise,totalPaise,imageUrl.discountCode: the discount code the order used, ornull. What it took off istotals.discountPaise.totals:subtotalPaise,discountPaise,shippingPaise,paymentFeePaise(such as the COD fee),taxPaise(the GST contained in the total) andtotalPaise(what the customer pays).payment:method,label,cod(true when cash is collected on delivery) andstatus(pending,paid,failed,refundedorpartially_refunded).tracking:company,numberandurl, each possiblynull. Set once the order is shipped.statusUrl: the page where the customer can follow the order (timeline, carrier and tracking number). Put it in your messages. The link is created when the event is delivered to you and works for 30 days, so a retry gets a fresh one. It can benullwhen the platform could not create one. Anyone who has the link can open that one order, so treat it like the customer's address.- Online payments (Razorpay):
order.createdandorder.paidare sent together, once the payment is confirmed, andorder.payment.statusis alreadypaidin both. Nothing is sent for an order that was never paid, so a shopper who abandons the payment page produces no order event. confirmedBy:merchant,customer(for example through an order reply),apporsystem.checkout.customer:name(may benull),email(may benull),phone(always present) andmarketingConsent.recoveryUrl: the link that reopens the cart (section 10).test.ping: sent by the Send test event button.datais{ "message": "..." }andmarketingOptInisnull.
order.created
{
"apiVersion": "2026-11-01",
"id": "evt_order_created_1",
"type": "order.created",
"createdAt": "2026-11-03T10:15:00.000Z",
"store": {
"id": "store_test_1",
"slug": "demo",
"name": "Demo Store",
"currency": "INR"
},
"marketingOptIn": {
"whatsapp": true,
"email": false
},
"data": {
"order": {
"id": "order_1001",
"number": 1001,
"name": "#1001",
"createdAt": "2026-11-03T10:15:00.000Z",
"status": "pending",
"currency": "INR",
"customer": {
"id": "cust_1",
"name": "Asha Verma",
"email": "[email protected]",
"phone": "+919876543210",
"marketingConsent": {
"whatsapp": true,
"email": false
}
},
"shippingAddress": {
"name": "Asha Verma",
"phone": "+919876543210",
"line1": "12 MG Road",
"line2": null,
"city": "Pune",
"state": "Maharashtra",
"pincode": "411001",
"country": "IN"
},
"lineItems": [
{
"productId": "prod_1",
"variantId": "var_1",
"sku": "MUG-BLU-L",
"title": "Blue Mug",
"variantTitle": "Large",
"quantity": 2,
"unitPricePaise": 29900,
"totalPaise": 59800,
"imageUrl": "https://demo.localhost/images/mug.jpg"
},
{
"productId": "prod_2",
"variantId": "var_2",
"sku": null,
"title": "Coaster Set",
"variantTitle": null,
"quantity": 1,
"unitPricePaise": 49900,
"totalPaise": 49900,
"imageUrl": null
}
],
"discountCode": null,
"totals": {
"subtotalPaise": 109700,
"discountPaise": 0,
"shippingPaise": 4900,
"paymentFeePaise": 5000,
"taxPaise": 18244,
"totalPaise": 119600
},
"payment": {
"method": "cod",
"label": "Cash on Delivery",
"cod": true,
"status": "pending"
},
"tracking": null,
"statusUrl": "https://checkout.grindshops.com/order/v1.eyJvaWQiOiIxZjBjOWQzZSJ9.c2lnbmF0dXJl"
}
}
}
order.paid
{
"apiVersion": "2026-11-01",
"id": "evt_order_paid_1",
"type": "order.paid",
"createdAt": "2026-11-03T10:15:00.000Z",
"store": {
"id": "store_test_1",
"slug": "demo",
"name": "Demo Store",
"currency": "INR"
},
"marketingOptIn": {
"whatsapp": true,
"email": false
},
"data": {
"order": "(the order, as in order.created)",
"paidAt": "2026-11-03T10:15:00.000Z"
}
}
order.confirmed
{
"apiVersion": "2026-11-01",
"id": "evt_order_confirmed_1",
"type": "order.confirmed",
"createdAt": "2026-11-03T10:15:00.000Z",
"store": {
"id": "store_test_1",
"slug": "demo",
"name": "Demo Store",
"currency": "INR"
},
"marketingOptIn": {
"whatsapp": true,
"email": false
},
"data": {
"order": "(the order, as in order.created)",
"confirmedAt": "2026-11-03T10:15:00.000Z",
"confirmedBy": "merchant"
}
}
order.shipped
{
"apiVersion": "2026-11-01",
"id": "evt_order_shipped_1",
"type": "order.shipped",
"createdAt": "2026-11-03T10:15:00.000Z",
"store": {
"id": "store_test_1",
"slug": "demo",
"name": "Demo Store",
"currency": "INR"
},
"marketingOptIn": {
"whatsapp": true,
"email": false
},
"data": {
"order": "(the order, as in order.created)",
"shippedAt": "2026-11-03T10:15:00.000Z",
"tracking": {
"company": "Delhivery",
"number": "DL123456789",
"url": "https://track.example.com/DL123456789"
}
}
}
order.delivered
{
"apiVersion": "2026-11-01",
"id": "evt_order_delivered_1",
"type": "order.delivered",
"createdAt": "2026-11-03T10:15:00.000Z",
"store": {
"id": "store_test_1",
"slug": "demo",
"name": "Demo Store",
"currency": "INR"
},
"marketingOptIn": {
"whatsapp": true,
"email": false
},
"data": {
"order": "(the order, as in order.created)",
"deliveredAt": "2026-11-03T10:15:00.000Z"
}
}
order.cancelled
{
"apiVersion": "2026-11-01",
"id": "evt_order_cancelled_1",
"type": "order.cancelled",
"createdAt": "2026-11-03T10:15:00.000Z",
"store": {
"id": "store_test_1",
"slug": "demo",
"name": "Demo Store",
"currency": "INR"
},
"marketingOptIn": {
"whatsapp": true,
"email": false
},
"data": {
"order": "(the order, as in order.created)",
"cancelledAt": "2026-11-03T10:15:00.000Z",
"reason": "Changed my mind"
}
}
refund.created
{
"apiVersion": "2026-11-01",
"id": "evt_refund_created_1",
"type": "refund.created",
"createdAt": "2026-11-03T10:15:00.000Z",
"store": {
"id": "store_test_1",
"slug": "demo",
"name": "Demo Store",
"currency": "INR"
},
"marketingOptIn": {
"whatsapp": true,
"email": false
},
"data": {
"refund": {
"id": "refund_1",
"orderId": "order_1001",
"amountPaise": 59800,
"currency": "INR",
"reason": null,
"createdAt": "2026-11-03T10:15:00.000Z"
},
"order": "(the order, as in order.created)"
}
}
customer.created
{
"apiVersion": "2026-11-01",
"id": "evt_customer_created_1",
"type": "customer.created",
"createdAt": "2026-11-03T10:15:00.000Z",
"store": {
"id": "store_test_1",
"slug": "demo",
"name": "Demo Store",
"currency": "INR"
},
"marketingOptIn": {
"whatsapp": true,
"email": false
},
"data": {
"customer": {
"id": "cust_1",
"name": "Asha Verma",
"email": "[email protected]",
"phone": "+919876543210",
"marketingConsent": {
"whatsapp": true,
"email": false
},
"createdAt": "2026-11-03T10:15:00.000Z"
}
}
}
checkout.started
{
"apiVersion": "2026-11-01",
"id": "evt_checkout_started_1",
"type": "checkout.started",
"createdAt": "2026-11-03T10:15:00.000Z",
"store": {
"id": "store_test_1",
"slug": "demo",
"name": "Demo Store",
"currency": "INR"
},
"marketingOptIn": {
"whatsapp": true,
"email": false
},
"data": {
"checkout": {
"id": "chk_1",
"createdAt": "2026-11-03T10:15:00.000Z",
"currency": "INR",
"customer": {
"name": null,
"email": null,
"phone": "+919876543210",
"marketingConsent": {
"whatsapp": true,
"email": false
}
},
"lineItems": [
{
"productId": "prod_1",
"variantId": "var_1",
"sku": "MUG-BLU-L",
"title": "Blue Mug",
"variantTitle": "Large",
"quantity": 2,
"unitPricePaise": 29900,
"totalPaise": 59800,
"imageUrl": "https://demo.localhost/images/mug.jpg"
},
{
"productId": "prod_2",
"variantId": "var_2",
"sku": null,
"title": "Coaster Set",
"variantTitle": null,
"quantity": 1,
"unitPricePaise": 49900,
"totalPaise": 49900,
"imageUrl": null
}
],
"subtotalPaise": 109700
}
}
}
checkout.abandoned
{
"apiVersion": "2026-11-01",
"id": "evt_checkout_abandoned_1",
"type": "checkout.abandoned",
"createdAt": "2026-11-03T10:15:00.000Z",
"store": {
"id": "store_test_1",
"slug": "demo",
"name": "Demo Store",
"currency": "INR"
},
"marketingOptIn": {
"whatsapp": true,
"email": false
},
"data": {
"checkout": {
"id": "chk_1",
"createdAt": "2026-11-03T10:15:00.000Z",
"currency": "INR",
"customer": {
"name": null,
"email": null,
"phone": "+919876543210",
"marketingConsent": {
"whatsapp": true,
"email": false
}
},
"lineItems": [
{
"productId": "prod_1",
"variantId": "var_1",
"sku": "MUG-BLU-L",
"title": "Blue Mug",
"variantTitle": "Large",
"quantity": 2,
"unitPricePaise": 29900,
"totalPaise": 59800,
"imageUrl": "https://demo.localhost/images/mug.jpg"
},
{
"productId": "prod_2",
"variantId": "var_2",
"sku": null,
"title": "Coaster Set",
"variantTitle": null,
"quantity": 1,
"unitPricePaise": 49900,
"totalPaise": 49900,
"imageUrl": null
}
],
"subtotalPaise": 109700
},
"abandonedAt": "2026-11-03T10:15:00.000Z",
"idleMinutes": 30,
"recoveryUrl": "https://checkout.grindshops.com/recover/0b5e2b0e-6d0c-4a49-9d2e-2d8f6f0a7a11.Zm9vYmFyYmF6cXV4Zm9vYmFyYmF6cXV4Zm9vYmFyYmF"
}
}
7. Order replies: confirm or cancel from your tool
This is the way back. Your tool calls a private address, and the order is confirmed or cancelled and a line is added to the order's timeline ("Order confirmed by the customer via whatsapp."). It can do nothing else: it cannot mark an order as paid, read orders, or change anything but confirm and cancel. Cancelling puts the items back in stock.
Create the address. On the Webhooks app page, under Order replies, press Create address. You get an address and a signing key, shown once. They look like this (made up):
https://grindshops.com/hooks/0b5e2b0e-6d0c-4a49-9d2e-2d8f6f0a7a11/webhooks/Zm9vYmFyYmF6cXV4Zm9vYmFyYmF6cXV4Zm9vYmFyYmF
Call it. Send a POST with Content-Type: application/json, a body of at most 16 KB, and sign it like a webhook:
X-GrindShop-Timestamp: the current time in Unix seconds.X-GrindShop-Signature:v1=plusHMAC-SHA256(key, timestamp + "." + body)in hexadecimal.
The body:
| Field | Meaning |
|---|---|
action | confirm or cancel (required) |
orderNumber | The number the customer knows: 1001, "1001" or "#1001" (required) |
by | customer (default) or app. Who decided; shown on the timeline |
reason | Why, for a cancellation (optional, up to 500 characters) |
channel | Where the customer answered, for example whatsapp (optional, lower case) |
Any other field is refused. Node.js example:
import { createHmac } from 'node:crypto';
const body = JSON.stringify({ action: 'confirm', orderNumber: 1001, channel: 'whatsapp' });
const timestamp = Math.floor(Date.now() / 1000);
const signature =
'v1=' +
createHmac('sha256', process.env.GRINDSHOP_HOOK_KEY).update(`${timestamp}.${body}`).digest('hex');
const response = await fetch(process.env.GRINDSHOP_HOOK_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-GrindShop-Timestamp': String(timestamp),
'X-GrindShop-Signature': signature
},
body
});
console.log(response.status, await response.json());
Before our code runs, the web server can refuse a request with a plain-text answer (not JSON): 411 for a body sent in pieces without a Content-Length (every HTTP library sets one for a body you pass as text), 413 for a body announced as larger than 64 KB, and 403 for a request with a form content type (application/x-www-form-urlencoded, multipart/form-data or text/plain) that does not come from our own site. Send application/json and none of these can happen.
Answers. Always JSON: { "ok": true, "code": "confirmed", "message": "...", "data": { "order": "#1001", "status": "confirmed", "changed": true } } on success, and { "ok": false, "code": "...", "message": "..." } otherwise.
| Status | code | Meaning |
|---|---|---|
| 200 | confirmed, cancelled | Done |
| 200 | already_confirmed, already_cancelled | It was already so; nothing changed |
| 200 | (same, with "duplicate": true) | You sent the same signed request again; this is the first answer |
| 400 | invalid_json, invalid_body | The body is not valid; issues says what is wrong |
| 401 | signature_required, signature_invalid, timestamp_invalid, timestamp_stale, timestamp_future | Not signed, wrongly signed, or the timestamp is not within 5 minutes |
| 403 | hook_disabled, app_disabled | You switched the address or the app off |
| 404 | not_found | Wrong address (it is the same answer for every wrong guess) |
| 404 | order_not_found | No order has that number |
| 405 | method_not_allowed | Any method other than POST |
| 409 | order_cancelled, order_delivered, order_already_shipped, order_already_paid, order_state_conflict | The order is past the point where this is possible |
| 409 | request_in_progress | The same request is being handled; try again in a moment |
| 413 | payload_too_large | More than 16 KB |
| 415 | unsupported_media_type | Send application/json |
| 429 | rate_limited | More than 60 requests a minute on this address, or 120 from one IP |
| 500, 503 | handler_error, app_not_configured, hook_unavailable | Our side; try again later (or save the app settings again) |
Replays. A signed request is accepted once. Sending the very same request again (same signature) returns the first answer with "duplicate": true and changes nothing, so it is safe to retry after a timeout. A request signed more than 5 minutes ago is refused.
Tools that cannot sign. Some no-code tools cannot compute an HMAC. In that case, switch off Require a signature on every request under Order replies. The page then warns you: anyone who learns the address could confirm or cancel orders. If you do this, send an Idempotency-Key header (up to 100 printable characters) with each call so repeats are recognised, and rotate the address if you ever suspect it leaked. Press Create a new address and key to replace both at once.
Send the address nowhere public. Do not put the address or the key in a web page, an app, a public repository or a shared document.
8. Interakt (WhatsApp)
The Interakt app sends your orders and abandoned carts to Interakt's Track API, so you can start campaigns and journeys from them. It sends data only to api.interakt.ai.
- In Interakt, open Settings > Developer Settings and copy your Secret Key. Interakt's Track API is not available on the Starter plan.
- In your admin, open Apps > Interakt, press Install app, and paste the key under Keys. It is stored encrypted and never shown again.
- Choose who may receive order updates and whether to send abandoned checkouts (section 9 explains the choices).
- In Interakt, create your campaigns or journeys and pick one of the events below as the trigger. WhatsApp message templates must be approved by WhatsApp first; that happens in Interakt.
For every message we first create or update the contact in Interakt (name, e-mail, whatsappMarketingOptIn, emailMarketingOptIn, and the tag Grind Shop), then send the event.
| Grind Shop event | Interakt event name | Useful properties |
|---|---|---|
order.created | OrderPlaced | orderNumber, orderValue, items, itemCount, paymentMethod, cashOnDelivery, customerName, shippingCity, orderStatusUrl |
order.paid | OrderPaid | the same, plus paidAt. Sent for online payments, and when you mark a cash on delivery order as paid (filter on cashOnDelivery) |
order.confirmed | OrderConfirmed | the same, plus confirmedAt |
order.shipped | OrderShipped | the same, plus shippedAt, trackingCompany, trackingNumber, trackingUrl |
order.delivered | OrderDelivered | the same, plus deliveredAt |
order.cancelled | OrderCancelled | the same, plus cancelledAt, cancelReason |
checkout.abandoned | CheckoutAbandoned | cartValue, items, itemCount, cartRecoveryUrl, abandonedAt, idleMinutes, customerName |
customer.created | (none) | Only creates or updates the contact |
Order values are in rupees (₹1,196.00 is 1196). Only Indian mobile numbers (+91) are sent for now. If Interakt refuses the key, the delivery log says so and asks you to enter it again.
9. Consent: who gets a message
At checkout, directly under the mobile number, there is a checkbox. It is never ticked in advance. Its text says that the shopper agrees to offers and cart reminders on WhatsApp, SMS and e-mail, that you keep their number, e-mail and name if they leave before ordering, how to stop (reply STOP, the unsubscribe link, or ask the store), and that order updates are sent either way.
What we store: when the shopper ticked it, where (checkout), and which wording they saw (a version label). It is saved with the order and, for a new customer, on the customer.
What events say: marketingOptIn is the state of the customer record, not just the box that was ticked. A person who types the e-mail or number of an existing customer cannot switch that customer's consent on, and cannot add an address to their account. For a shopper the store does not know yet, marketingOptIn is what they ticked. We cannot prove the number belongs to the person who typed it, so:
- reminders for the same number go out at most once every 24 hours;
- put "reply STOP to opt out" in your WhatsApp templates and honour it;
- a double opt-in (the shopper confirms with a code) is the next step if you need proof.
How the apps use it:
- Interakt, abandoned checkouts: only for shoppers whose WhatsApp consent is on. Order events follow your choice: _nobody_, _only shoppers who agreed to WhatsApp messages_ (the safe default) or _every customer who gave a mobile number_. The last one suits transactional templates about an order the customer just placed.
- Webhooks: order events are always sent to the endpoints that ask for them (they are about an order the customer placed).
checkout.startedandcheckout.abandonedare sent only when the shopper agreed, unless you switch off Send checkout events only for shoppers who agreed to messages.
This is a description of how the product behaves, not legal advice. India's Digital Personal Data Protection Act asks for a clear, specific, freely given agreement, a plain notice and an easy way to withdraw. Add the same promises to your store's privacy policy.
10. Abandoned checkouts and the recovery link
- When a shopper types a valid mobile number at checkout, it is saved against the cart (only in browsers that run scripts) and
checkout.startedis sent once per cart. - When the cart then stays idle for 60 minutes without an order,
checkout.abandonedis sent exactly once for that cart, even if several of our workers run at the same time. - The event carries the phone number, name (if typed), the items, the subtotal,
marketingOptInand arecoveryUrl. - The recovery link is valid for 7 days. It opens a fresh checkout for the same cart with the items that can still be bought. Nothing about the shopper is filled in; they type it again. When they have ordered in the meantime, or nothing in the cart can be bought any more, the link says so. An expired or wrong link shows a short message and a link back to your store.
- Only a fingerprint of the link is stored, so it cannot be read back from our database.
11. When something was not sent
Open the app on the Apps page and look at the Delivery log. Every row says what happened, in plain words, and what to do. The most common reasons:
| Message | What it means |
|---|---|
Skipped: no_marketing_consent | The shopper did not agree to messages. Nothing is sent, on purpose |
Skipped: order_updates_off, abandoned_checkouts_off | You switched that kind of message off in the app settings |
Skipped: unsupported_phone | The number is not an Indian mobile number |
Skipped: no_endpoint | No active endpoint is set to receive this event |
Failed: not_configured | The key is missing. Paste it again under Keys |
Failed: http_status_401 or 403 | The other service refused the key or the request |
Failed: http_status_404 | The address does not exist |
Failed: http_status_410 | The address was removed on purpose; the endpoint is paused |
Failed: http_status_429 | The other service says we are sending too fast; we try again |
Failed: http_status_5xx | A problem on the other side; we try again |
Failed: http_timeout | No answer within 8 seconds; we try again |
Failed: http_blocked_address | The address points to a private or internal network |
Failed: http_tls_failed | The certificate of the address is not valid |
Failed: http_redirect | The address redirects; give the final address |
Failed: max_attempts | All 8 attempts failed. Fix the cause, then press Try again |
For most failures we try again by ourselves. For the others, fix the cause (for example the address or the key) and press Try again on that row: the event is sent again from the start. Requests that already succeeded are not sent twice.
Limits: a store can make 6,000 outgoing requests an hour, and every request has 8 seconds to be answered. If you need more, tell us.
What next
Back to the guides, or contact us if something here does not match what you see.