> ## Documentation Index
> Fetch the complete documentation index at: https://docs.watx.in/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom store

> Connect a store you built yourself: your server sends Watx signed order, cart and product events, and Watx can call back to create discount codes and cancel orders.

Use this when your shop is not on Shopify or WooCommerce — a store your team built, in any
language. Instead of Watx reading your store's API, your server **sends** Watx its orders,
carts and products in a small signed JSON format. From then on the store works like any
other: it gets the [store console](/integrations/store-console), order and cart
[automations](/automations/triggers), [cash on delivery confirmation](/integrations/cash-on-delivery),
invoices from orders, and AI agents that can sell and give discount codes.

This page is for the business owner and the developer who wires it up.

<Tip>
  Building it with Claude Code, Cursor or Copilot? Start from the ready-made
  [AI assistant prompt](/integrations/custom-store-ai) — it carries this whole page, the
  JSON Schema and a way for the assistant to check its own work.
</Tip>

## Connect the store

<Steps>
  <Step title="Open the card">
    **Integrations → Custom store → Connect**. You need the admin or owner role.
  </Step>

  <Step title="Name the store">
    Enter the **Store name**, the **Store address** (for example `https://shop.example.com`)
    and the **Currency** your catalogue is priced in (three letters, `INR` by default).
    Press **Connect**.
  </Step>

  <Step title="Copy the URL and the secret">
    The **Send events** step shows the **Webhook URL** and the **Signing secret**. Copy the
    secret now — it is shown once. Store it in your server's environment, never in browser
    code.
  </Step>

  <Step title="Send a first event">
    Have your server send a `ping` event (below). The store shows **Waiting for the first
    event** until Watx receives one it can verify, then switches to **Connected**.
  </Step>

  <Step title="Add a callback URL (optional)">
    On the **Callback URL** step, enter an https URL on your server where Watx can send
    requests — to create a discount code or cancel a cash-on-delivery order. Press
    **Send a test** to check it answers.
  </Step>
</Steps>

A workspace can connect one custom store. To change its name, address, currency or
callback URL later, open the store console and choose **Connection settings** from the
menu.

## Sending events

Every event is an HTTPS `POST` to your webhook URL with a JSON body of this shape:

```json theme={null}
{
  "event": "order.created",
  "data": { "id": "1042", "...": "..." }
}
```

Sign the **raw body** with HMAC-SHA256 using your signing secret, and send the hex digest
in the `X-Watx-Signature` header, optionally prefixed with `sha256=`. The event name is
inside the signed body, so it cannot be changed without breaking the signature.

<CodeGroup>
  ```javascript Node.js theme={null}
  import crypto from 'node:crypto';

  async function sendToWatx(event, data) {
    const body = JSON.stringify({ event, data });
    const signature =
      'sha256=' +
      crypto.createHmac('sha256', process.env.WATX_SECRET).update(body).digest('hex');

    const res = await fetch(process.env.WATX_WEBHOOK_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'X-Watx-Signature': signature },
      body,
    });
    return res.json(); // { received: true, outcome: "order placed; not cod" }
  }

  await sendToWatx('ping', {});
  ```

  ```python Python theme={null}
  import hashlib, hmac, json, os, requests

  def send_to_watx(event, data):
      body = json.dumps({"event": event, "data": data}, separators=(",", ":"))
      digest = hmac.new(os.environ["WATX_SECRET"].encode(), body.encode(), hashlib.sha256).hexdigest()
      res = requests.post(
          os.environ["WATX_WEBHOOK_URL"],
          data=body,  # send exactly the bytes you signed
          headers={"Content-Type": "application/json", "X-Watx-Signature": f"sha256={digest}"},
          timeout=10,
      )
      return res.json()

  send_to_watx("ping", {})
  ```

  ```php PHP theme={null}
  <?php
  function send_to_watx(string $event, array $data): array {
      $body = json_encode(['event' => $event, 'data' => $data]);
      $signature = 'sha256=' . hash_hmac('sha256', $body, getenv('WATX_SECRET'));
      $ch = curl_init(getenv('WATX_WEBHOOK_URL'));
      curl_setopt_array($ch, [
          CURLOPT_POST => true,
          CURLOPT_POSTFIELDS => $body,
          CURLOPT_HTTPHEADER => ['Content-Type: application/json', "X-Watx-Signature: $signature"],
          CURLOPT_RETURNTRANSFER => true,
      ]);
      return json_decode(curl_exec($ch), true);
  }

  send_to_watx('ping', []);
  ```
</CodeGroup>

<Warning>
  Sign the exact bytes you send. Serialising the object a second time after signing — or
  letting an HTTP library re-encode it — changes the bytes and every delivery is refused as
  a forgery.
</Warning>

Watx always answers `200`, with `{ "received": true, "outcome": "…" }`. The `outcome` says
what happened — `pong`, `order placed; not cod`, `bad signature`, `unknown event …`. The
console's **Overview** tab lists every delivery under **Recent deliveries**, refused ones
included, and the **Events** table shows when each event type was last received.

When Watx accepted an event but could not use part of it, the outcome ends with
` — check:` and the fields, for example:

```text theme={null}
order placed; not cod — check: customer.phone missing (nobody can be messaged about this order); total is not an amount like "1499.00"
```

The event is still recorded — a warning never refuses a delivery — but the fields it names
were ignored. Treat any `— check:` as something to fix.

### Events

| Event | Send it when | What Watx does |
| - | - | - |
| `ping` | Testing | Nothing, apart from marking the store connected |
| `order.created` | An order is placed | Creates or updates the contact and a WhatsApp conversation, fires **Order placed**, starts [cash on delivery confirmation](/integrations/cash-on-delivery) for a COD order |
| `order.paid` | Payment is taken | Records the order as paid; settles an open invoice raised for it |
| `order.fulfilled` | The order ships | Fires **Order fulfilled** |
| `order.cancelled` | The order is cancelled | Fires **Order cancelled** and closes a pending COD confirmation |
| `order.refunded` | The order is refunded | Fires **Order refunded** |
| `order.updated` | Anything else changes | Updates the stored order; fires nothing |
| `checkout.updated` | A cart is created or changed | Stores the cart; it becomes **abandoned** after the time set on the console's **Carts** tab |
| `checkout.deleted` | A cart is emptied or removed | Stops cart recovery for it |
| `product.updated` | A product is created or changed | Adds or updates it in the catalogue |
| `product.deleted` | A product is removed | Removes it from the catalogue |

Each transition happens once. A second `order.created` for the same order — a retry after a
timeout, say — is treated as an update and does not send the customer a second
confirmation. The same goes for `order.cancelled`, `order.fulfilled` and `order.refunded`.
If the first event Watx sees for an order is not `order.created` (for example `order.paid`
arrives first), that first event counts as the order being placed.

## Event data

### Data types

| Type | Send | Example |
| - | - | - |
| string | Text | `"Cotton saree"` |
| id | A string (a number is accepted). The same value every time for the same thing | `"1042"` |
| money | Major units as a decimal string, or a number. No currency symbols or commas | `"1499.00"` |
| integer | A whole number | `2` |
| boolean | `true` or `false` | `true` |
| datetime | ISO 8601, with an offset | `"2026-10-18T10:00:00+05:30"` |
| url | An `http(s)://` URL | `"https://shop.example.com/orders/1042"` |
| currency | ISO 4217, three letters | `"INR"` |
| country | ISO 3166-1 alpha-2, two letters | `"IN"` |
| phone | International format. A national number is read in your workspace's country | `"+919876543210"` |
| email | An email address | `"ravi@example.com"` |
| list of strings | An array of strings | `["WA-7K3Q9P"]` |

Leave out a field you do not have rather than sending an empty string. `null` counts as
not sent. Field names are exact; Watx ignores fields it does not know.

### The JSON Schema

Every event and every callback below is published as a JSON Schema (2020-12):

```text theme={null}
https://api.watx.in/webhooks/store/schema.json
```

Validate your payloads against it in your own tests, or generate types from it. It
documents the canonical field names only and rejects unknown ones, which catches a
`phone_number` sent for `phone`. Building with an AI assistant? See
[Build it with an AI assistant](/integrations/custom-store-ai).

### Orders

```json theme={null}
{
  "event": "order.created",
  "data": {
    "id": "1042",
    "number": "#1042",
    "created_at": "2026-10-18T10:00:00+05:30",
    "currency": "INR",
    "total": "1499.00",
    "subtotal": "1400.00",
    "discount": "0.00",
    "shipping": "99.00",
    "tax": "0.00",
    "taxes_included": true,
    "payment_method": "cod",
    "payment_status": "pending",
    "fulfillment_status": "unfulfilled",
    "status_url": "https://shop.example.com/orders/1042",
    "customer": {
      "name": "Ravi Kumar",
      "phone": "+919876543210",
      "email": "ravi@example.com"
    },
    "shipping_address": {
      "line1": "12 MG Road",
      "line2": "Flat 4B",
      "city": "Bengaluru",
      "state": "KA",
      "postal_code": "560001",
      "country_code": "IN"
    },
    "items": [{ "title": "Cotton saree", "quantity": 2, "price": "700.00" }],
    "shipping_method": "Standard",
    "checkout_id": "cart_abc",
    "discount_codes": ["WA-7K3Q9P"]
  }
}
```

Every `order.*` event carries the whole current order in this shape.

| Field | Type | Required | Notes |
| - | - | - | - |
| `id` | id | Yes | Your order id. The same id on every event for the order. |
| `number` | string | No | What the customer sees, e.g. `"#1042"`. Defaults to `id`. |
| `created_at` | datetime | No | When the order was placed. |
| `cancelled_at` | datetime | No | On `order.cancelled`. Defaults to now. |
| `currency` | currency | No | Defaults to the store's currency. |
| `total` | money | Yes | The amount charged. |
| `subtotal` | money | No | Goods before the order discount. Without it, the items are added up. |
| `discount` | money | No | Order-level discount. |
| `shipping` | money | No | Shipping charged. |
| `tax` | money | No | Tax charged. |
| `taxes_included` | boolean | No | `true` when prices already include tax. |
| `payment_method` | string | No | `cod` (or anything containing "cash on delivery") marks a cash-on-delivery order. Otherwise the gateway name. |
| `payment_status` | string | No | `pending`, `paid`, `partially_paid`, `refunded`, `partially_refunded`, `voided`. `order.paid` and `order.refunded` imply it if you leave it out. |
| `fulfillment_status` | string | No | `unfulfilled`, `partial`, `fulfilled`. `order.fulfilled` implies `fulfilled`. |
| `status_url` | url | No | A page where the customer can see the order. Without it, orders show no link. |
| `customer` | object | For messaging | Without `customer.phone`, the order is recorded but nobody can be messaged. |
| `customer.name` | string | No | Full name. Or send `first_name` and `last_name` (both string). |
| `customer.phone` | phone | For messaging | |
| `customer.email` | email | No | |
| `shipping_address` | object | No | Saved on the contact for next time. |
| `shipping_address.line1` | string | Yes, inside the address | Street address. |
| `shipping_address.line2` | string | No | Flat, floor, landmark. |
| `shipping_address.city` | string | No | |
| `shipping_address.state` | string | No | Name or code. |
| `shipping_address.postal_code` | string | No | |
| `shipping_address.country_code` | country | No | |
| `items` | list of objects | No | What was bought. |
| `items[].title` | string | Yes, in each item | Product name. |
| `items[].quantity` | integer | No | Defaults to 1. |
| `items[].price` | money | No | The **unit** price. |
| `shipping_method` | string | No | e.g. `"Standard"`. |
| `checkout_id` | id | No | The `id` of the cart this order came from, so it is marked recovered. |
| `discount_codes` | list of strings | No | Codes used on the order. A code an AI agent gave is marked **Used**. |

### Carts

```json theme={null}
{
  "event": "checkout.updated",
  "data": {
    "id": "cart_abc",
    "currency": "INR",
    "total": "1400.00",
    "items": [{ "title": "Cotton saree", "quantity": 2, "price": "700.00" }],
    "recovery_url": "https://shop.example.com/cart/recover/abc",
    "customer": {
      "name": "Ravi Kumar",
      "phone": "+919876543210",
      "accepts_marketing": true
    },
    "created_at": "2026-10-18T09:40:00+05:30",
    "updated_at": "2026-10-18T09:52:00+05:30"
  }
}
```

| Field | Type | Required | Notes |
| - | - | - | - |
| `id` | id | Yes | Your cart id. |
| `currency` | currency | No | |
| `total` | money | Yes | Cart total. |
| `items` | list of objects | No | Same shape as an order's items. |
| `recovery_url` | url | Recommended | The link that reopens **this** cart. Without it the reminder has nothing to send. |
| `customer` | object | Recommended | Same shape as an order's customer, plus `accepts_marketing` (boolean). Without a phone or email the cart cannot be matched to anybody. |
| `created_at` | datetime | No | |
| `updated_at` | datetime | No | When it last changed. Abandonment is measured from this. |

`checkout.deleted` carries only `{ "id": "cart_abc" }`.

`recovery_url` is the link that reopens this cart — it is what the abandoned-cart message
sends. `accepts_marketing` is used when the console's **Carts** tab is set to message only
shoppers who agreed to marketing. A cart only becomes a WhatsApp message once it has a
phone number that matches a contact, or once the order that follows creates one.

### Products

```json theme={null}
{
  "event": "product.updated",
  "data": {
    "id": "sku-1001",
    "title": "Cotton saree",
    "description": "Handwoven, 6.2 m",
    "price": "700.00",
    "image_url": "https://cdn.shop.example.com/saree.jpg",
    "url": "https://shop.example.com/p/cotton-saree",
    "inventory": 12,
    "active": true
  }
}
```

| Field | Type | Required | Notes |
| - | - | - | - |
| `id` | id | Yes | Your product id or SKU. |
| `title` | string | Yes | Product name. |
| `description` | string | No | Plain text or HTML. |
| `price` | money | Yes | In the currency you chose when connecting. |
| `image_url` | url | No | Main image. |
| `url` | url | No | The product page. AI agents send this link. |
| `inventory` | integer or null | No | Units in stock. `null` means stock is not tracked. |
| `active` | boolean | No | `false` takes the product out of the catalogue. Defaults to `true`. |

`product.deleted` carries only `{ "id": "sku-1001" }`.

Prices are read in the currency you chose when connecting. Send `"active": false`, or a
`product.deleted` event, to take a product out of the catalogue. There is no sync button
for a custom store: to fill the catalogue the first time, send `product.updated` once for
each product.

## The callback URL

With a callback URL saved, Watx sends your server signed requests for the things only your
store can do. Each is an HTTPS `POST` with the same envelope and the same
`X-Watx-Signature` header, signed with the same secret — so your server can check it came
from Watx — plus an `X-Watx-Event` header. Answer with any `2xx` status once the action is
done; anything else is reported back as a failure, using the `error` or `message` field of
your JSON response if it has one.

```json theme={null}
{
  "id": "3f6c2a1e-…",
  "event": "coupon.create",
  "created_at": "2026-10-18T10:05:00.000Z",
  "data": { "...": "..." }
}
```

| Event | When | `data` |
| - | - | - |
| `ping` | You press **Send a test** | `store` (string), `message` (string) |
| `coupon.create` | An AI agent gives a customer a discount code | See below |
| `order.cancel` | A customer cancels a cash-on-delivery order, or it is cancelled automatically | `order_id` (id), `reason` (string, always `"customer"`), `note` (string) |
| `order.tag` | A cash-on-delivery order is confirmed or cancelled, and a tag is set on the COD tab | `order_id` (id), `tags` (list of strings) |

The envelope's `id` is a string unique to each request, `event` a string and `created_at` a
datetime.

`coupon.create` data:

| Field | Type | Notes |
| - | - | - |
| `code` | string | Create exactly this code. Upper-case; match it case-insensitively. |
| `percent_off` | integer | 1–100, off everything. |
| `starts_at` | datetime | Valid from. |
| `expires_at` | datetime | Valid until. |
| `usage_limit` | integer | Always `1`. |
| `minimum_order` | money or null | Minimum order in major units, or `null` for none. |
| `currency` | currency | The store's currency. |
| `customer` | object | `name`, `phone`, `email` — each a string or `null`. |
| `reason` | string or null | Why the agent gave it, for your records. |

For `coupon.create`, create a single-use code exactly as given — Watx has already sent the
customer that code. Codes are upper-case; treat them as case-insensitive at checkout.

<CodeGroup>
  ```javascript Node.js (Express) theme={null}
  import crypto from 'node:crypto';
  import express from 'express';

  const app = express();

  app.post('/watx/callback', express.raw({ type: 'application/json' }), (req, res) => {
    const expected = crypto
      .createHmac('sha256', process.env.WATX_SECRET)
      .update(req.body) // the raw bytes
      .digest('hex');
    const given = (req.get('X-Watx-Signature') ?? '').replace(/^sha256=/, '');
    if (
      given.length !== expected.length ||
      !crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected))
    ) {
      return res.status(401).json({ error: 'bad signature' });
    }

    const { event, data } = JSON.parse(req.body.toString('utf8'));
    if (event === 'coupon.create') {
      // createSingleUseCoupon(data.code, data.percent_off, data.expires_at, data.minimum_order)
    }
    res.json({ ok: true });
  });
  ```

  ```python Python (Flask) theme={null}
  import hashlib, hmac, os
  from flask import Flask, request, jsonify

  app = Flask(__name__)

  @app.post("/watx/callback")
  def watx_callback():
      raw = request.get_data()
      expected = hmac.new(os.environ["WATX_SECRET"].encode(), raw, hashlib.sha256).hexdigest()
      given = request.headers.get("X-Watx-Signature", "").removeprefix("sha256=")
      if not hmac.compare_digest(given, expected):
          return jsonify(error="bad signature"), 401

      payload = request.get_json()
      if payload["event"] == "coupon.create":
          pass  # create the single-use code from payload["data"]
      return jsonify(ok=True)
  ```
</CodeGroup>

The callback URL must be https and reachable from the internet. Watx refuses a private or
local address when you save it, and gives up on a request after 10 seconds.

## Discount codes from AI agents

With a callback URL saved, turn on **Give discount codes** on an
[AI agent](/ai-agents/skills) and choose the most it may ever give. When the agent decides a
customer should have a discount, Watx makes up a one-time code, sends your store a
`coupon.create` request, and only then gives the customer the code. Every code is listed
on the store console's **Discount codes** tab, with who received it, which agent gave it
and why, and whether it was used.

## Rotating the secret

Open **Connection settings**, go to **Send events** and press **Rotate secret**. The old
secret stops working immediately: events your store signs with it are refused until you
deploy the new one, and your callback handler should switch at the same time.

## Disconnecting

**Connection settings → Disconnect** removes the store and its orders, carts, catalogue
and discount codes from the workspace, along with the message automations built for it.
Your server keeps sending until you remove the code that does; those deliveries are
refused.

## Limits

* One custom store per workspace.
* Watx never reads from your store — there is no sync. Products arrive only as you send
  them.
* The AI agent's **Sell from the chat** skill sends a product's page (`url`), not a
  pre-filled basket link: Watx cannot build basket links for a store it does not know.
* The console shows no **Open admin** link, because Watx does not know where your admin is.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.