The commerce API is how an online store, a point-of-sale system or a spreadsheet-driven back office keeps its products and orders in step with Momo Business — and through Momo Business, with WhatsApp and every other platform a shop is published on.
A catalogue (a shop) holds products, brands, categories and orders. It is complete and useful with no platform attached at all: the assistant, the phone menus and the staff app all read it. A channel is that shop's presence on one platform. A listing is one product as one platform sees it. That separation is why the same product can be live on your storefront and blocked on WhatsApp, and why each says so in its own words instead of leaving you to guess.
Create the shop, then fill it
POST /api/v3/catalogues makes a shop from your own system, so an integration can finish its setup without anyone opening a dashboard. Connecting that shop to WhatsApp stays a dashboard step — it needs a consent a bearer token cannot give on a person's behalf. Read channels[] on any catalogue response to see where it is published.
{"name":"Mango Electronics","default_currency":"TZS","sku_prefix":"MNG","stock_policy":"external"}
stock_policy decides who owns the stock count. external means your system owns it: we mirror what you send and tell you what sold, and we never decide a new number on your behalf. momo means we keep the count. A shop created through this API defaults to external; one created in the dashboard or from a spreadsheet defaults to momo. PATCH /api/v3/catalogues/{catalogue} changes any of it afterwards. sku_prefix is accepted on create only — it is stamped into every code the shop has already issued.
Product identity and money
| Value |
Meaning |
sku |
Your product code, and the identity this API addresses a product by |
retailer_id |
The older name for sku, kept in step with it |
Catalogue id / product id |
Our numeric ids, still valid in every path |
price / sale_price |
Whole numbers of the minor unit — 4500000 is TZS 45,000.00 |
You never have to invent a code. Send no sku and the shop issues one — MNG-00042 — readable, sortable and unique per shop. Once a product is live on any platform its code is frozen: platforms treat it as the item's identity, and changing it would orphan the remote item and silently create a duplicate.
Only name and price are required. An image, a non-zero price and the rest are what individual platforms require, and a product missing them is stored and reported as blocked on that platform rather than refused outright. A shop selling only on its own storefront should not have to satisfy Meta's rules.
{"sku":"MNG-45W","name":"Charger Mango 45W","description":"USB-C PD, 1 m cable","price":4500000,"currency":"TZS","sale_price":3900000,"image_url":"https://cdn.example.com/mng45.jpg","inventory":12,"availability":"in stock","brand":"Mango","category":"Chargers"}
Leave currency out and the shop's own is used. Leave availability out and it is derived from inventory: a count of zero means out of stock unless the shop allows backorders. Send availability explicitly and what you send wins. Brand and category text becomes records on first use, so one import fills the pick lists for everything typed afterwards.
The bulk sync
POST /api/v3/catalogues/{catalogue}/products/batch is the endpoint a store integration lives on. Up to 5,000 products, every documented field stored — not just the identity ones.
It answers 202, not 200. The rows are accepted, and applying them and pushing them to a platform happens in the background, because that takes longer than an HTTP request should. The response carries a sync id.
POST /api/v3/catalogues/42/products/batch
Idempotency-Key: nightly-2026-09-11
{"mode":"upsert","products":[ ... ]}
Send Idempotency-Key on anything scheduled. Repeating a key returns the run that already owns it with "replayed": true and imports nothing, so a cron that times out is safe to retry.
mode is upsert by default: products your payload does not mention are left alone. mode: "replace" says this payload is the catalogue and retires everything missing from it — correct for a full nightly export, destructive for a partial one.
Rows are matched on sku when you send one, and on the product name when you do not, so a sheet with no code column updates its rows on a re-import instead of duplicating them.
One bad row does not refuse the file. A row that cannot be stored becomes one rejected row in the report; the other 4,999 land.
No developer? Point us at a feed
Every store platform — Shopify, WooCommerce, Magento, PrestaShop, Wix — can publish a Google Shopping feed or a Meta product feed with a setting, no code. POST /api/v3/catalogues/{catalogue}/feeds points a shop at that URL and we pull it on a schedule.
{"url":"https://shop.example.com/google-feed.xml","schedule":"daily"}
The first pull runs at once. Column names are guessed from the feed's own headers (g:id, g:price, g:availability and the rest are all known), kept on the feed, and can be corrected with mapping. Every pull is a sync like any other — read last_sync_id at GET /catalogues/{catalogue}/syncs/{sync} for the same report the batch endpoint produces. mode defaults to replace because a feed is normally the whole catalogue; set upsert if yours is partial.
A feed is one-way, so put the shop under stock_policy: external: the store owns the count, the feed states it, and orders here are reported back through webhooks for the store to act on. CSV and JSON feeds work the same way. After ten failed pulls in a row the feed is switched off and the account is told; fix it and switch it back on. POST …/feeds/{feed}/run pulls now, whatever the schedule.
Read the report
GET /api/v3/catalogues/{catalogue}/syncs/{sync} is the other half of the contract. Poll it until status is completed or failed.
{"status":"completed","received":2000,"created":12,"updated":1982,"rejected":6,"retired":0,
"platforms":{"whatsapp":{"synced":1960,"blocked":34}},
"problems":[{"sku":"MNG-CABLE","stage":"ingest","reason":"Price must be a whole number of minor units, 0 or more."},
{"sku":"MNG-KNIFE","stage":"whatsapp","reason":"WhatsApp needs a product image it can fetch."}]}
stage is the field to read first. ingest means the row was not stored at all — fix it and send it again. Any other value is a platform name: the product is stored and correct on our side, and that platform will not show it until the stated problem is fixed. Both look like "my product is not live", and they need different actions. At most 200 problems are kept; problems_truncated says when there were more.
GET /api/v3/catalogues/{catalogue}/syncs lists past runs, newest first, including feed pulls and spreadsheet imports.
Changing one product
PUT /api/v3/catalogues/{catalogue}/products/by-sku/{sku} addresses a product by the code your system already knows it by, so a sync never has to keep a map of our ids. It creates the product when there is none, so a store that has just added an item does not have to know whether we have seen it before.
Only the fields you send are touched. {"inventory": 0} is a stock update and nothing else — it does not blank the description.
GET and DELETE work the same way on the same path. Deleting takes the product off every platform it is on, then off the shelf. The numeric-id paths (/products/{product}) do the same thing and are unchanged.
Product reads carry listings[]: one row per platform with its state (pending, syncing, synced, failed, blocked, drifted) and the problem where there is one. sync_status on the product is a roll-up of those rows — a product in a shop with no platforms is synced, because there is genuinely nothing to sync.
Platforms, and who is right
A shop is published to platforms through channels; a product is one listing per platform. The same product can be synced on your storefront, blocked on WhatsApp (no image), and pending on Instagram at the same time, and each row says so.
Drift. A merchant who edits a price directly in Meta Commerce Manager has made a decision, and the next push from your system or from momo used to overwrite it in silence. Every hour, each platform's copy is compared with ours. A difference marks the listing drifted, records the field-by-field drift, sends product.drifted to your webhooks, and then applies the shop's rule:
source_of_truth |
What happens |
api (default for shops made through this API) or momo |
Our copy wins: it is pushed back and the platform falls into line. |
platform |
Their copy wins: the changed fields are pulled into our row. |
Stock is never part of the comparison — it is a ledger, and platforms only ever see the availability word derived from it. image_url is not compared either, because Meta re-hosts every image it fetches.
Sending a product. POST /api/v3/catalogues/send-product takes a platform. On WhatsApp it is Meta's interactive product card, as before. On any other platform the honest answer is a link: the response carries the product's address there and the endpoint to send it with, so there is one send path for every platform rather than one per platform. A product not published on that platform is refused with a 422 that says so. send-product-list and send-catalogue are WhatsApp's own interactive types with no equivalent elsewhere, and stay WhatsApp-only.
Stock, in both directions
Stock is a ledger, and every shop declares who owns it.
Under stock_policy: external — the default for a shop created through this API — your system owns the count. Push levels with the endpoint below, and we mirror them and tell you what sold. A confirmed sale never decrements on our side, because your system is about to state the new level and the two must not both subtract. We still hold units for a pending order between your syncs, so the same last unit is never promised to two customers.
Under momo — the default for a shop made in the dashboard or from a spreadsheet — we keep the count. A pending order reserves units; confirming it commits them; cancelling releases them; staff adjust by hand with a reason.
Either way, a product with no inventory at all is not tracked. Nobody typed a count, so nothing is held back and nothing is ever refused. Most products start this way and that is not a fault.
Push levels
POST /api/v3/catalogues/{catalogue}/inventory is what to call the moment something sells on your own site. Up to 5,000 rows, keyed by your own codes, nothing but the count.
{"levels":[{"sku":"MNG-45W","inventory":0},{"sku":"MNG-20W","inventory":37}]}
Levels are absolute, never deltas: your system is stating what it has, and a delta would drift the first time a message was delivered twice.
Availability follows the count. Unless the shop allows backorders, 0 sets the product to out of stock and stock coming back lifts it to in stock. The response names every product where that happened under derived, and one push goes to every platform the shop is on — so a sell-out reaches WhatsApp without you sending six whole products one at a time. A discontinued or preorder product is never quietly put back on sale by a delivery arriving.
Codes we do not have come back in unknown_skus rather than being ignored. A level already at that value is counted unchanged and nothing is re-published for it.
Read levels and history
GET /api/v3/catalogues/{catalogue}/inventory returns, per product, inventory (on hand), reserved (held by pending orders) and available — the difference, and the number that decides whether a customer may buy. Filter with sku[] or tracked_only=true.
GET /api/v3/catalogues/{catalogue}/products/by-sku/{sku}/movements is the audit trail: every event that moved the count, newest first, with kind (reserve, release, commit, adjust, sync), the signed quantity, what the figures became, and who or what caused it.
What an order does to stock
When an order is created — from a WhatsApp cart, your storefront, a flow, or the phone menus — units are reserved for every line we can match. If there was not enough to hold, the line is flagged stock_short, the order is flagged needs_attention, and the order is still recorded: a customer asked for it, and losing that would be worse. A line whose code is not in the catalogue is flagged unresolved for the same reason.
Moving the order to confirmed (or any later state) commits the hold — a real decrement under momo, a released hold under external. cancelled or refunded releases it. A pending order that is never answered releases on its own once the shop's reservation_ttl_hours (48 by default) has passed.
Set low_stock_threshold on the shop and you are told, once a day per product, when available reaches it.
Who may do what
An API key inherits the permissions of the person who issued it, never more. Reading needs communications.catalogue.view; writing needs communications.catalogue.manage. A key issued before that link was recorded carries no permission set to check: it keeps working on the endpoints that existed before this contract and is refused on the ones added by it. If a write starts answering 403, issue a new key from Settings → API credentials under a user who holds the permission.
Sending products into WhatsApp
/catalogues/send-product, /catalogues/send-product-list and /catalogues/send-catalogue put products into a chat. They take to for the recipient and an optional from for the sending identity, and they use the provider catalogue id and product_retailer_id, not our numeric ids.
Single product requires catalogue_id and product_retailer_id; optional body ≤ 1,024 and footer ≤ 60. Product list requires header_text ≤ 60, body ≤ 1,024 and 1–10 sections, each with a title ≤ 24 and product_items. Whole catalogue requires body and can name a thumbnail_product_retailer_id.
These call the provider directly and return data.message_id, a gateway id — not the ordinary REST message uid. No WhatsApp account configured returns 422; a provider refusal returns 502.
Orders
GET /api/v3/catalogues/orders lists orders from every platform, not only WhatsApp. platform says where each came from — whatsapp, storefront, ivr, manual — and customer_handle is whatever identifies the customer there: a WhatsApp id, a handle, an email, a typed phone number. customer_wa_id is the older name for it and has not held only WhatsApp ids for a long time.
Filter with status, platform and catalogue_id. updated_since is the cursor to poll from when you need to recover from a callback you missed; results then come newest-change-first.
PUT /api/v3/catalogues/orders/{order}/status moves an order through pending, confirmed, processing, shipped, delivered, cancelled, refunded. It records the change and moves the stock the order is holding (see above); it does not enforce a linear graph and setting refunded does not move money.
Read needs_attention before fulfilling. When it is true, at least one line is stock_short (we could not hold the whole quantity) or unresolved (the code is not in the catalogue), and a person has to decide what to do.
An order's lines and total are frozen when it is created. A later price change in the catalogue never re-prices an open order — the customer bought at the price they were shown. Keep fulfilment status separate from payment settlement: paid means money settled, not that a request was sent.