Skip to content

Message24 Integration API v1

The HTTP contract a system implements so Message24 can sync its products and push orders to it.

Markdownllms.txt

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.

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.

{
"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?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.

{
"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.

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.

{
"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.

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

{
"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.

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.

{
"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:

{ "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.

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:

{ "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.

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.

Enough to be connectable and sell from, in Express:

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)

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.

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.