# Message24 Integration API v1

This is the contract a third-party system implements so that Message24 can read its product catalog, and optionally receive the orders our AI captures in chat.

It exists because most of the shops we sell to already run something — a bespoke ERP, a POS, an internal admin — and the alternative is writing a dedicated integration in our codebase for each of them. If you implement the three endpoints below, a business pastes a URL and a key into Message24 Settings > Integrations and the catalog is live. No code in our repository, no release of ours to wait for.

Our side is `api/internal/integrations/m24api`. The Go types in `types.go` are the normative version of everything described here.

## Shape of the thing

You expose a base URL. Everything is relative to it, so `https://erp.acme.com/message24` means we call `https://erp.acme.com/message24/products`.

| Method | Path | Required | Purpose |
|---|---|---|---|
| `GET` | `/` | yes | Discovery: what you are and what you support |
| `GET` | `/products` | yes | The catalog, paginated |
| `POST` | `/orders` | only if you declare `orders` | Receive an order |

Requests carry these headers:

```
Authorization: Bearer <the key you issued>
Accept: application/json
X-Message24-API-Version: 1
User-Agent: Message24-Integration/1
```

The key is issued by you and pasted into Message24 by the business. We store it encrypted and send it on every request. Reject anything without it with `401`.

HTTPS only. A base URL entered as `http://` is upgraded to `https://` before the first request, because the key travels on every call. Your endpoint must also be reachable from the public internet: we refuse to connect to private, loopback, link-local and carrier-grade-NAT addresses, and no configuration flag turns that off.

## `GET /` — discovery

```json
{
  "apiVersion": "1",
  "name": "Acme ERP",
  "capabilities": ["products", "orders"]
}
```

This is the connection test. When a business hits Connect, this single request decides whether the integration is saved, and its failure message is what they see. A URL returning HTML, or JSON without `apiVersion`, is rejected as not being an implementation of this spec.

`capabilities` drives the settings page: the "push orders" toggle is only offered to a system that lists `orders`. Listing `products` is mandatory — there is nothing to connect without a catalog.

## `GET /products` — the catalog

```
GET /products?page=1&limit=100&updatedSince=2026-09-11T14:00:00Z
```

`page` is 1-based. `limit` is what we would like; return fewer if you prefer and we will follow `hasMore` regardless. `updatedSince` is present only on incremental syncs (see below) and asks for products created, changed, or deactivated since that RFC 3339 timestamp. **You may ignore `updatedSince` entirely** — returning the whole catalog every time is a correct, if wasteful, implementation.

```json
{
  "products": [ ... ],
  "page": 1,
  "hasMore": true,
  "total": 1240
}
```

`hasMore` is what drives our loop; `total` is only used for progress display and may be omitted. If you echo `page`, echo the one we asked for — a response claiming a different page means you are paginating on something other than our parameter, and we stop rather than import the same page repeatedly.

### The product object

Only `externalId` and `name` are required. A catalog of nothing but ids, names and prices is a complete implementation and the AI will sell from it. Omitting `inStock` means in stock.

```json
{
  "externalId": "SKU-1042",
  "name": "Ceramic Mug",
  "description": "350ml stoneware mug.",
  "sku": "MUG-350-BLU",
  "price": 12.5,
  "inStock": true,
  "active": true,
  "priceStrategy": "fixed",

  "nameAr": "كوب سيراميك",
  "nameCkb": "جامی سیرامیک",
  "descriptionAr": "...",
  "descriptionCkb": "...",

  "category": "Kitchen > Drinkware > Mugs",
  "brand": "Acme",

  "originalPrice": 18.0,
  "discountEndsAt": "2026-09-30T23:59:59Z",

  "aiNotes": "Dishwasher safe. Not for microwave use.",
  "images": ["https://cdn.acme.com/mug-1.jpg", "https://cdn.acme.com/mug-2.jpg"],
  "videoUrl": "https://cdn.acme.com/mug.mp4",

  "options": [],
  "variants": []
}
```

**`externalId`** is the join key. We match on it to decide insert versus update, and it must be stable for the life of the product — if it changes, the old product is deactivated and a duplicate appears.

**`inStock`** gates whether the AI offers the product, and defaults to `true` when omitted (on variants too). Return an out-of-stock product with `inStock: false` rather than omitting the product, so a customer asking for it gets "that's out of stock right now" instead of "we don't carry that".

**`active`** hides a product without deleting it. Omit it and the product is active.

**`priceStrategy`** is `"fixed"` (the default) or `"quote"` for a product whose price is deliberately not public — the AI then routes price questions to a human instead of quoting. Send `"quote"` only when that is a real policy in your system. A zero price is almost always a half-entered product, not a pricing decision, so do not infer it.

**Translations** are optional. The untranslated fields are the fallback, so a single-language catalog just omits them.

**`category`** is a path, deepest last, separated by `>`. Levels are created on demand and the product is filed under the leaf. `brand` is a plain name. Both are matched by name, so spell them consistently — "Acme" and "ACME" become two brands.

**Discounts.** `originalPrice` and `discountEndsAt` describe a discount that is live *right now*: `price` is already the discounted selling price and `originalPrice` is what it was struck through from. Both must be present, `originalPrice` must exceed `price`, and `discountEndsAt` must be in the future, or we ignore the pair and keep the price. Do not send scheduled or expired discounts — `price` is stored as the selling price unconditionally, so a future discount sent early is simply a wrong price quoted to customers today.

**`aiNotes`** is free text the AI may draw on when answering questions but will not read out verbatim: ingredients, care instructions, fitment, warranty terms.

**`images`** are absolute URLs, most representative first. We download and re-host them, once per product, so they may sit behind a CDN but must not require the API key. We keep the first five.

### Variable products

A product sold in several configurations declares its axes in `options` and its purchasable combinations in `variants`.

```json
{
  "externalId": "TEE-1",
  "name": "Cotton T-Shirt",
  "price": 20,
  "options": [
    { "name": "Colour", "values": ["Black", "White"] },
    { "name": "Size", "values": ["S", "M", "L"] }
  ],
  "variants": [
    {
      "externalId": "TEE-1-BLK-M",
      "options": { "Colour": "Black", "Size": "M" },
      "price": 22,
      "inStock": true,
      "sku": "TEE-BLK-M",
      "images": ["https://cdn.acme.com/tee-black.jpg"]
    },
    {
      "externalId": "TEE-1-WHT-S",
      "options": { "Colour": "White", "Size": "S" },
      "inStock": false
    }
  ]
}
```

Every variant must name every option, using values that appear in that option's list. A variant referencing a value the options never declared cannot be ordered, because the customer is never offered it.

The `options` array is optional — we derive the axes from the variants if you omit it — but declaring it is how you control the order values are presented in. Declared, sizes are offered "S, M, L"; derived, they are alphabetical: "L, M, S". An option value that no variant uses is dropped, since nothing can be bought with it.

A variant with no `price` inherits the parent's. Variant `images` are attached to that variant's option values, so the customer sees the right photo for the colour they asked about.

Omit both arrays for a simple product.

## `POST /orders` — orders in

Only if your discovery document lists `orders`. Turning the toggle on for a system that doesn't declare it is refused at connect time rather than failing silently later.

```json
{
  "id": "6f1c9d2e-...-a41b",
  "orderNumber": "1042",
  "customer": {
    "name": "Sara",
    "phone": "+9647501234567",
    "email": "",
    "address": "Erbil, 100m St, building 4"
  },
  "items": [
    {
      "productExternalId": "TEE-1",
      "variantExternalId": "TEE-1-BLK-M",
      "name": "Cotton T-Shirt",
      "variantName": "Black / M",
      "quantity": 2,
      "unitPrice": 22
    }
  ],
  "total": 49,
  "discount": 0,
  "deliveryFee": 5,
  "notes": "Call before delivery"
}
```

Answer with the reference the order now has in your system:

```json
{ "externalId": "SO-00412", "url": "https://erp.acme.com/orders/412" }
```

`externalId` is required and is shown to shop staff, so return the reference a human would use to look the order up — an order number beats a UUID. `url` is optional and deep-links staff to the order.

**Idempotency is not optional.** The push is a retrying background job, so the same order will sometimes arrive twice. Every request carries `Idempotency-Key: <the order's id>`, which is also the `id` field in the body. A repeat must return the reference you already assigned, not create a second order.

Any field of `customer` may be empty. Orders are captured in chat, and a customer who hasn't given their address yet still has an order worth recording. `productExternalId` and `variantExternalId` are the ids your own `/products` response gave us; they are empty for a custom item the AI captured that was never in the catalog, in which case `name` is all there is.

`total` is what the customer agreed to pay, with `deliveryFee` added and `discount` already subtracted. It is sent so you can reject an order you would price differently rather than silently disagreeing with what the customer was told.

## Errors

Any non-2xx is a failure. If you return a body, we read `error` or `message` from it and show that to the business in the sync log:

```json
{ "error": "Catalog is being rebuilt, try again in a minute" }
```

A `401` or `403` is reported to the business as a rejected API key, which is nearly always what it is. Bare status codes work too; the body only determines how useful the message is.

## How syncing behaves

Products are synced on a schedule and whenever the business presses Sync.

A **full sync** walks every page with no `updatedSince`. It is the only thing that detects deletions: anything we hold for this integration that the crawl no longer lists is deactivated. A full sync that returns zero products on page one is refused rather than acted on — that is far more likely to be a broken endpoint than a shop that deleted its entire catalog, and we are not going to deactivate everything on the strength of an empty array.

An **incremental sync** sends `updatedSince` and runs when the previous sync succeeded and the last full sync was less than 24 hours ago. The timestamp is the last successful sync minus one hour, which absorbs clock skew and products that were mid-write as we read. Incremental responses never deactivate anything, since what they omit is simply unchanged.

So: deletions and deactivations reach us within a day even if you implement `updatedSince` perfectly. If something must disappear sooner, return it with `active: false` — that is picked up incrementally.

We stop after 5,000 pages with `hasMore` still true and log it as an error. At the default limit that is half a million products, so tripping it means `hasMore` is hardcoded.

## A minimal implementation

Enough to be connectable and sell from, in Express:

```js
const express = require('express')
const app = express()
app.use(express.json())

const KEY = process.env.M24_KEY

app.use((req, res, next) => {
  if (req.get('authorization') !== `Bearer ${KEY}`) {
    return res.status(401).json({ error: 'invalid api key' })
  }
  next()
})

app.get('/', (req, res) => {
  res.json({ apiVersion: '1', name: 'Acme ERP', capabilities: ['products'] })
})

app.get('/products', async (req, res) => {
  const page = Number(req.query.page) || 1
  const limit = Math.min(Number(req.query.limit) || 100, 200)
  const { rows, total } = await catalog.page({ page, limit, since: req.query.updatedSince })

  res.json({
    page,
    total,
    hasMore: page * limit < total,
    products: rows.map(r => ({
      externalId: String(r.id),
      name: r.title,
      description: r.body,
      sku: r.sku,
      price: Number(r.price),
      inStock: r.qty > 0,
      active: !r.archived,
      category: r.categoryPath,      // "Kitchen > Drinkware > Mugs"
      brand: r.brand,
      images: r.photoUrls,
    })),
  })
})

app.listen(8080)
```

## Checking your work

Before handing the URL to a business, confirm these by hand. Each one is a real failure we have seen from an integration that "worked":

- `GET /` with no `Authorization` header returns `401`.
- `GET /products?page=2` returns the *second* page, not the first.
- The last page returns `hasMore: false`. Walk the whole catalog and count: the total you walked should match `total`.
- `externalId` for a given product is identical across two separate runs.
- Re-POSTing an order with the same `Idempotency-Key` returns the same `externalId` and does not create a second order.
- Image URLs open in a private browser window with no session and no key.
- Every variant's `options` names every option in `options[]`, with values from those lists.

Then in Message24: Settings > Integrations > Integration API > Connect, paste the base URL and key, and press Sync. Sync Activity on that page shows what happened, including per-product errors — a product that fails to map is skipped and named there rather than failing the whole run.

## Versioning

`apiVersion` is `"1"`. Fields will be added to this contract; nothing will be renamed or repurposed within a version. Ignore fields you don't recognise in what we send you, and send only what you have — every optional field is genuinely optional.

If we ever need a breaking change it becomes version `"2"` and version `"1"` implementations keep working, which is the whole reason discovery carries a version at all.
