Matrix Bundles
Matrix Bundles from a terminal
Every step from nothing to a saved change, as commands you can run. Get a key, keep it out of your shell history, prove it works, read what the app will accept, rehearse a change, put a basket through the real pricing engine, then save.
This is switched on.
The API and the assistant connector are switched on. Claude and ChatGPT connect by signing in, and the merchant approves them inside their own Shopify admin. Anything else uses a key the merchant makes on the app’s Access keys screen.
- The addresses answer. Checked against the live app on 28 September 2026.
- Keys can be made, by the merchant, on the Access keys screen in the app.
This page is the shell. What the two ways in are, how a merchant makes and revokes a key, and how an assistant that is not at a terminal connects are on the connect page. Every setting a bundle has, and the JSON for each, is in the configuration reference.
Everything here uses curl and nothing else. There is no library to install, no SDK and nothing to sign.
Get a key
Open Matrix Bundles in your Shopify admin and choose Access keys, the item above Help in the app’s menu. Under Make a key, give it a Name if you want one, choose Permissions — Read only, or Read and change — and press Make a key. The key is shown once, in a box headed “Copy this key now”. Nobody else can make one for you and nobody here can read yours.
A key looks like this:
mxb_live_your-shop_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Your shop’s address is part of the key, in plain sight and on purpose: a request carrying nothing but the key already says which shop it is for. There is no shop parameter to send. From a terminal you use a key; Claude and ChatGPT can sign in instead, as the connect page explains.
Choose read, or read and write
You pick one when you make the key, and it is fixed for that key’s whole life. There is no widening it later; you make a second key instead.
A read key can call these ten:
describelist_bundlesget_bundlevalidate_bundlesimulate_cartget_statusfind_collectionsfind_variantsget_stylevalidate_strings
And it cannot call these five, whatever asks it to:
save_bundleset_bundle_statusdelete_bundlereorder_bundlessave_style
Over HTTP a read key asking for one of the five is refused with 403 and the sentence “This key can read but not change anything. Make a key with write access and use that instead.” On the assistant connector it is not even offered them: the tool list a read key gets has ten tools in it, not fifteen. The refusal is the lock; the shorter list is a courtesy.
A read key cannot make a bundle, change one, take one live, put one back to draft, delete one, change the order bundles are tried in, or change how the builder looks. It can read every one of those things, check a draft it will never save, run a basket through the pricing engine, check the shop’s setup, and look up collections and gifts by name. If a program only needs to look, give it a read key — it is the safe one.
The rest of what a key does
- You see the whole key once, when you make it. It is not stored anywhere and it cannot be shown again. Lose it and you revoke it and make another.
- A shop can hold ten live keys. At ten, revoke one you no longer use before making another. The list holds thirty in all, live and revoked together, so you can see what you turned off and when; past thirty the oldest revoked ones drop off it.
- You can label it, up to 40 characters, for you. It is optional.
- Last used is a date, not a time. It is written at most once a day, so it tells you a key is in use, not when the last call was.
- A revoked key is turned away with exactly the same answer as a key that never existed — so whoever is holding a dead one cannot learn it was ever alive.
- Nobody at this end can read your key. What the app keeps is a one-way scramble of it, so even a copy of the app’s own records cannot be turned back into a working key.
Put the key in the environment, not in the command
Type a key into a command and your shell writes that line to your history file in plain text, where it stays until you go and delete it — and while the command is running, anyone else on that machine can read it out of the process list. Read it into a variable instead. The line that goes into your history is then the variable’s name, not your key.
Run this once per terminal. It prints nothing back while you paste:
read -rs MXB_KEY
export MXB_KEY
export MXB=https://bundles.matrixhealthgroup.co.uk
read -rs takes the key without echoing it to the screen, so it is not left sitting in a scrollback either. MXB is the app’s address, set here once so every command below is the same wherever you run it.
Two things that are not the same as being safe. The key is still in that terminal’s environment, so anything you run from it can read the variable. And a key with write access can change your bundles, and your bundles set prices — so treat it like a password, keep it off any machine you do not control, and never put it in a web page, a theme, or anything a shopper’s browser loads.
Prove the key works
One call, and it reads nothing off the shop, so there is nothing it can break:
curl -sS -H "Authorization: Bearer $MXB_KEY" "$MXB/api/v1/schema"
A working key answers 200 with a description of the app — the same document the next section fetches without a key. Anything else means the key was not accepted.
A key that is not accepted answers 401, exactly this, whatever was wrong with it:
{
"error": {
"code": "unauthorized",
"message": "This request needs a working API key. Send it as `Authorization: Bearer <key>`, using a key made in the app that has not been revoked.",
"messages": [
"This request needs a working API key. Send it as `Authorization: Bearer <key>`, using a key made in the app that has not been revoked."
]
}
}
That is the real answer from the live app, not an illustration. It is also the answer for no key, a made-up key, a key for a shop this app cannot reach and a key revoked this morning — all four, deliberately the same, so an outsider learns nothing from which one they got.
Ask the app what it will accept
Before you write anything that builds a bundle, read this. It needs no key, it reaches no shop, and it is generated from the app’s own model — so it is the app describing itself rather than somebody’s notes about it.
curl -sS "$MXB/api/v1/schema.json"
The schema 1.7 answer, printed from the release’s own code on 1 October 2026, trimmed. The markers are in the block: this is an excerpt, and the whole document is longer.
{
"schema_version": "1.7",
"app": {
"name": "Matrix Bundles",
"publisher": "Matrix Health Group Ltd",
... trimmed: listing, docs, support and privacy addresses ...
},
"what_it_does": [ ... trimmed: four sentences ... ],
"bundles": {
"pricing": {
"how_they_save": [
{ "id": "percentage", "label": "Percentage off" },
{ "id": "tiered_percentage", "label": "More items, bigger saving" }
],
"not_released_yet": [
{ "id": "fixed_price", "label": "Set price" },
{ "id": "amount_off", "label": "Money off" }
],
"not_released_note": "These are in the code and are not offered to anyone. A merchant cannot pick them and a save carrying one is refused.",
"max_tiers": 6,
"tier_min_items": 2,
"tier_title_max_characters": 100
},
"limits": {
"config_byte_ceiling": 9500,
... trimmed: the note explaining that ceiling ...
},
... trimmed: steps, the order bundles are tried in, the cart line property, discount codes, free gifts, the checkout's copy of the rules ...
},
... trimmed: discount_rules, styling (125 controls), the fields a change takes, lookups ...
"operations": [
... trimmed: describe, list_bundles, get_bundle, validate_bundle, simulate_cart ...
{
"name": "save_bundle",
... trimmed: returns, writes, summary ...
"http": [
{
"method": "POST",
"path": "/api/v1/bundles",
"note": "Only makes a bundle. Send no id: a new bundle's id comes from its title. An id already on the store is refused with 409 (bundle_exists)."
},
{
"method": "PATCH",
"path": "/api/v1/bundles/{id}",
"note": "Changes the bundle named in the address. Send only what changes, with config_version."
}
],
"takes": { ... trimmed: every field a bundle change reads ... }
},
... trimmed: the other nine operations ...
],
... trimmed: the note on operations, the glossary, admin_only, what changed, the refusals, the manual index ...
"write_access": {
"available": true,
"note": "Two doors reach one shop's bundles and styling: /api/v1 over HTTP, and /mcp for an assistant. Both take a key as `Authorization: Bearer <key>`, both run the same code as the app's own Save button, and a key can only change a shop if it was made with write. The two doors take different shapes. The connector's save_bundle both makes a bundle and changes one. Over HTTP those are two addresses: POST /api/v1/bundles only makes a bundle, and PATCH /api/v1/bundles/{id} changes one. Every address and its method is listed at https://www.matrixhealthgroup.co.uk/apps/matrix-bundles/cli/, and connecting an assistant at https://www.matrixhealthgroup.co.uk/apps/matrix-bundles/connect/. The merchant makes a key on the app's Access keys screen, and it is shown once; a request with no key, or one that has been revoked, is turned away.",
... trimmed: the http and connector addresses ...
}
}
The parts that decide whether your request will be accepted:
-
bundles.pricing.how_they_save— the ways of saving this release can actually save. Send anything else and the save is refused by name. Read this rather than assuming: the list is generated from the app’s own release gate, so it moves the day one more is switched on. -
bundles.pricing.not_released_yet— the ones that exist in the code and are offered to nobody. They are named here so you know why a field you found in a schema is refused. -
bundles.limits.config_byte_ceiling— how big all of a shop’s bundles may be together, in bytes. A save that would go over it is refused. This is a different limit from the 64 KB one request may be. -
operations— all fifteen calls. Each one lists under http every method and address that runs it, with a note where the address alone does not say enough, and under takes the fields it reads. -
write_access.available— whether a key can be made at all. When it is false, everything on this page is refused for want of one, and the note beside it says why in the app’s own words.
GET /api/v1/schema is the same document, and it needs a key. GET /api/v1/schema.json needs none and is cacheable for an hour. Neither reaches a shop. GET /api/v1 on its own, with a key, answers with the list of every method and address.
List the bundles, read one
curl -sS -H "Authorization: Bearer $MXB_KEY" "$MXB/api/v1/bundles"
The shape that comes back:
{
"config_version": "the stamp — keep it",
"schema_version": "1.7",
"config_schema_version": 1,
"bundles": [
{
"id": "cream-trio",
"title": "Cream trio",
"status": "draft",
"pricing": { "type": "percentage", "value": 10 },
"slots": [ ... ],
"priority": 1,
"sentence": "the same plain sentence the app shows on its own screen"
}
]
}
Keep config_version. It is a stamp of what you just read, and every change you make has to carry it back. That is what stops two programs — or a program and a merchant on the screen — quietly undoing each other. A change with no stamp is refused rather than guessed at.
Every bundle also comes back with a sentence on it. That is the same plain sentence the app shows the merchant, written by the app, and it is the thing to put in front of a person rather than your own reading of the JSON.
The bundles come back in the order checkout tries them, and each carries priority, its place in that order: 1 is tried first, and a draft keeps its place but is skipped. The first bundle a cart satisfies takes those items. A bundle change cannot move a bundle in that order; the order has a call of its own, under Save it.
One bundle on its own:
curl -sS -H "Authorization: Bearer $MXB_KEY" "$MXB/api/v1/bundles/cream-trio"
That answers with the bundle and the same stamp. An id is a slug made from the merchant’s own title; it is set once and never moves, so renaming a bundle does not change how you address it.
On a shop on the new builder it also carries the bundle’s page settings, as bundle.page, and a second stamp beside config_version called page_version. Keep that one too if you will change the page. Every page field is on Configure bundles. On the old builder page is null, and a warning says why.
Check the setup, and find collections and gifts by name
Three reads, each a GET, each fine for a read key. None of them changes anything.
Is the shop set up?
curl -sS -H "Authorization: Bearer $MXB_KEY" "$MXB/api/v1/status"
The shape that comes back:
{
"checkout": { "working": true, "function_active": true, "config_present": true, "in_sync": true, "reason": null },
"config_bytes": { "used": ..., "limit": 9500, "percent_used": ..., "note": null },
"bundles": { "live": ..., "drafts": ... },
"broken_bundles": [ ... ],
"builder": "new",
"missing_pages": [ ... ],
"theme": { "builder_block": "cant_tell", "link_block": "cant_tell", "why": "..." },
"discounts": { "plan_notice": null, "plan_notice_note": "only when the notice could not be checked" }
}
The checks the app’s Setup and status screen makes, in its words. checkout says whether bundling works at checkout, and reason says why not when it does not. config_bytes is how full the shop’s 9,500 bytes of rules are. broken_bundles lists live bundles pointing at a collection that is gone, as the screen does, and missing_pages the live bundles the builder can draw that have no bundle page. theme is always cant_tell: whether the builder blocks are placed in the theme can only be seen in the app, in the merchant’s browser. discounts.plan_notice is the notice that a plan change will switch discount rules off; when it could not be checked, plan_notice_note says so, so a null there is not an all-clear. Pass reason and each message to the merchant as they are.
Find a collection
curl -sS -H "Authorization: Bearer $MXB_KEY" "$MXB/api/v1/collections?query=serum&limit=5"
The shape that comes back:
{
"query": "serum",
"collections": [
{
"id": "gid://shopify/Collection/123",
"title": "Serums",
"handle": "serums",
"products": 12,
"products_at_least": false,
"online_store": true
}
],
"more": false
}
The id is what a step’s match or a left-out line takes. online_store says whether the Online Store shows the collection; the builder can only draw one it does. products_at_least true means Shopify stopped counting and products is a floor.
Find a free gift
curl -sS -H "Authorization: Bearer $MXB_KEY" "$MXB/api/v1/variants?query=balm"
The shape that comes back:
{
"query": "balm",
"variants": [
{
"variant_id": "gid://shopify/ProductVariant/456",
"product_id": "gid://shopify/Product/789",
"title": "Mini Aftercare Balm",
"product_title": "Mini Aftercare Balm",
"variant_title": null,
"status": "ACTIVE",
"price": "...",
"available": true,
"sku": null,
"gift_card": false,
"subscription_only": false,
"can_be_gift": true
}
],
"more": false
}
The same filter as the app’s gift picker: no draft or archived products, and no bundle’s hidden helper product. can_be_gift is false for a gift card or a product sold only on subscription.
- query is words from the name. Leave it out and the list starts from the beginning of the alphabet. For variants, brackets and quotes are read as spaces, so send words only. query and limit are read from the address by these two searches and by nothing else.
- limit is 20 unless you send one, and at most 40. For variants it counts products, and each product lists up to 20 of its variants, with a warning naming any product that has more.
- more is true when there were more than you asked for. Narrow the words rather than paging.
Rehearse the change before you save it
This is the call worth building a habit around. It tells you what saving your draft would be refused for, and warned about, without saving it. It reads the shop and writes nothing, and a read key can run it.
curl -sS -X POST "$MXB/api/v1/bundles/validate" \
-H "Authorization: Bearer $MXB_KEY" \
-H "Content-Type: application/json" \
-d '{
"bundle": {
"id": "cream-trio",
"pricing": { "type": "percentage", "value": 15 }
}
}'
The shape that comes back:
{
"config_version": "the stamp",
"would_save": false,
"errors": [ "every sentence the save would have refused with" ],
"warnings": [ "every sentence it would have warned about" ],
"can_save_tiered": true,
"config_bytes_after": { "used": ..., "limit": 9500, "percent_used": ..., "note": null },
"bundle": { ... the bundle as it would have been stored, with its priority, and page if you sent page ... }
}
It needs no stamp, because it changes nothing. It runs the same two steps a real save runs, in the same order, and the sentences in errors are word for word the ones the save would have refused with.
It reads the shop on purpose. Half the refusals a merchant meets are about the rest of the store rather than the bundle in front of them — an id already taken, an earlier bundle that will eat this basket first, a list at its ceiling. A check that knew only your draft would pass all of those and the save would still fail.
It also answers can_save_tiered, whether this shop may save a tiered price at all, and config_bytes_after, how full the shop’s 9,500 bytes of rules would be after this change, with the Setup and status screen’s note from 70% full. A change that sends page gets bundle.page back as it would be saved.
What it does not run is what needs a write: minting the hidden helper product a bundle needs to go live, and whether the storefront’s copy of the rules lands. So would_save true is a strong signal, not a promise.
Run a basket through the real pricing engine
Not a model of it, and not an estimate: the same engine that decides what happens at checkout, run against this shop’s own products. Name the variants and it says which bundle took which lines, and why the others took nothing.
curl -sS -X POST "$MXB/api/v1/bundles/simulate" \
-H "Authorization: Bearer $MXB_KEY" \
-H "Content-Type: application/json" \
-d '{
"bundle_id": "cream-trio",
"lines": [
{ "variant_id": "gid://shopify/ProductVariant/123", "quantity": 2 },
{ "variant_id": "gid://shopify/ProductVariant/456", "quantity": 1 }
]
}'
Leave the lines out and the app builds the basket a shopper would most likely build from this shop’s real products, and runs that instead. It is the quickest way to see whether a rule does anything at all:
curl -sS -X POST "$MXB/api/v1/bundles/simulate" \
-H "Authorization: Bearer $MXB_KEY" \
-H "Content-Type: application/json" \
-d '{ "bundle_id": "cream-trio" }'
The shape that comes back, with your own basket:
{
"currency": "GBP",
"subtotal": 60,
"discount": 9,
"total": 51,
"merged_title": "what the one cart line is called",
"applies": true,
"why_not": null,
"merges": [
{ "bundle_id": "cream-trio", "title": "...", "percentage": 15, "tier_min_items": null, "lines": [ ... ] }
],
"skipped": [
{ "bundle_id": "another-bundle", "reason": "why that one took nothing" }
],
"added_for": [
{ "bundle_id": "...", "reason": "only when some lines were added for a bundle they did not make" }
]
}
- Variant ids in full, like gid://shopify/ProductVariant/123. A handle matches nothing.
- Up to 20 different products in one basket. The quantity on each line is not what the cap counts.
- The prices, the tags and the collections come from the shop, never from you. You cannot describe a basket into merging.
- why_not is the plain reason this bundle took nothing, when it took nothing. When another bundle took the items, it names that bundle. It is the field to read first when a merchant says “this cart did not work”.
Lines added to the cart for a bundle
A line can carry the properties it would be added to the cart with. Checkout reads two: _mx_for, the bundle the line was added for, which is tried first with that line, and _mx_gift_for, a bundle’s free gift. Anything else is ignored, as checkout ignores it. So a basket a theme’s own button will add can be tried before the button goes live:
curl -sS -X POST "$MXB/api/v1/bundles/simulate" \
-H "Authorization: Bearer $MXB_KEY" \
-H "Content-Type: application/json" \
-d '{
"bundle_id": "cream-trio",
"lines": [
{ "variant_id": "gid://shopify/ProductVariant/123", "quantity": 1, "properties": { "_mx_for": "cream-trio" } },
{ "variant_id": "gid://shopify/ProductVariant/456", "quantity": 1, "properties": { "_mx_for": "cream-trio" } }
]
}'
added_for lists the bundles some lines were added for that those lines did not make, and why, including an id no live bundle has: a title sent instead of the id, a typo, or a draft. Those lines were then offered to every bundle in the usual order, which merges and skipped show. The property is described for theme developers in the schema, under bundles.line_property.
Save it
A change carries the stamp you last read. Everything you leave out stays as it is — you do not send a whole bundle back to change one thing, and a program that has never heard of a setting cannot wipe it.
curl -sS -X PATCH "$MXB/api/v1/bundles/cream-trio" \
-H "Authorization: Bearer $MXB_KEY" \
-H "Content-Type: application/json" \
-d '{
"pricing": { "type": "percentage", "value": 15 },
"config_version": "THE-STAMP-YOU-JUST-READ"
}'
One exception to “what you leave out is kept”: sending slots (the steps), exclude (the things left out) or gifts replaces that whole list rather than adding to it. Send the whole list when you send any of them, and send gifts as an empty list to remove them all.
Make a new one
A POST to the collection, with no id in the address. This address only ever makes a bundle — it can never change an existing one. Leave the id out of the body too. A body that names a bundle already on the shop is refused with 409, the reason bundle_exists and a sentence naming the PATCH address to use; a body that names any other id is refused with 422. Before 30 September 2026 it saved a copy with -2 on the end of its id.
curl -sS -X POST "$MXB/api/v1/bundles" \
-H "Authorization: Bearer $MXB_KEY" \
-H "Content-Type: application/json" \
-d '{
"bundle": {
"title": "Morning routine",
"slots": [
{ "min": 1, "title": "Cleanser", "match": { "mode": "collection", "value": "gid://shopify/Collection/123" } },
{ "min": 1, "title": "Moisturiser", "match": { "mode": "collection", "value": "gid://shopify/Collection/456" } }
],
"pricing": { "type": "percentage", "value": 10 }
},
"config_version": "THE-STAMP-YOU-JUST-READ"
}'
It answers 201, and a location header carrying the new bundle’s address. You do not choose the id: it is made from the title.
Change the bundle’s page
The page settings go inside page, with only what you are changing, and carry page_version as well as config_version, both from your read of that one bundle:
curl -sS -X PATCH "$MXB/api/v1/bundles/cream-trio" \
-H "Authorization: Bearer $MXB_KEY" \
-H "Content-Type: application/json" \
-d '{
"page": { "mode": "guided", "cta": { "fr": "Ajouter" } },
"config_version": "THE-STAMP-YOU-JUST-READ",
"page_version": "THE-PAGE-VERSION-YOU-JUST-READ"
}'
That shows the steps one at a time and changes the French button label; every other language stored is kept, and null or "" clears one. A value the app cannot read, listed in page.unreadable, blocks a page change until you name its path in page.discard. A page setting sent outside page is refused with where it goes. It is saved by the bundle screen’s own Save, and the answer carries the new page_version and bundle.page as stored.
A step’s main-language title is slots[].title; page.stages.<step id>.title takes the other languages only, and title: null clears every one. A tier’s ladder label is page.tiers[i].label. A step that picks by tag takes no descSource or imageSource. A stale page_version is refused with 409 before anything else about the page is looked at.
Take it live
There is no separate address for this over HTTP. Going live is a change like any other — send the status with the stamp:
curl -sS -X PATCH "$MXB/api/v1/bundles/cream-trio" \
-H "Authorization: Bearer $MXB_KEY" \
-H "Content-Type: application/json" \
-d '{ "status": "live", "config_version": "THE-STAMP-YOU-JUST-READ" }'
Live is the moment real shoppers start being charged differently, and it is the moment the app makes the hidden helper product the merge needs. Simulate first.
Change the order bundles are tried in
Checkout tries live bundles in one order, first to last, and the first one a cart satisfies takes those items. To change it, send every bundle’s id once, first tried first, with the stamp you read. list_bundles gives them in today’s order. Move up and Move down on Your bundles do the same, through the same writer as Save.
curl -sS -X POST "$MXB/api/v1/bundles/order" \
-H "Authorization: Bearer $MXB_KEY" \
-H "Content-Type: application/json" \
-d '{
"ids": [ "morning-routine", "cream-trio" ],
"config_version": "THE-STAMP-YOU-JUST-READ"
}'
The shape that comes back:
{
"saved": true,
"order": [ "morning-routine", "cream-trio" ],
"bundles": [
{ "id": "morning-routine", "title": "Morning routine", "status": "live", "priority": 1 },
{ "id": "cream-trio", "title": "Cream trio", "status": "draft", "priority": 2 }
],
"config_version": "the new stamp — keep it",
"warnings": [ "only when there is something to say" ]
}
saved is false when the bundles were already in that order, and nothing was written. Keep the new config_version for your next change. A new bundle still goes last, so the order changes only when somebody changes it.
When the save lands and the store is slow to answer afterwards, it still answers saved, with “Saved. The store was slow to answer afterwards, so the notes a save gives about the new order were not checked. validate_bundle gives them.” When the store cannot be read back at all, it answers 503 with “We couldn’t read this store back just now, so we can’t say whether the new order was saved. Read list_bundles before you try again.”
Delete one
A DELETE has no natural place for a body, so the stamp travels in the address instead. It is not a secret — it is a stamp of what you read.
curl -sS -X DELETE \
"$MXB/api/v1/bundles/cream-trio?config_version=THE-STAMP-YOU-JUST-READ" \
-H "Authorization: Bearer $MXB_KEY"
Everything here runs the same code as the app’s own Save button. A change made from a terminal is checked the same way, and a refusal comes back in the same words a merchant would have read on the screen.
Every refusal you will actually hit
A refused call answers with the same shape every time:
{
"error": {
"code": "refused",
"message": "every sentence, joined into one line for a log",
"messages": [
"the first thing wrong",
"the second thing wrong"
]
}
}
Read messages, not message. The joined line is for a log; the list is what to put in front of a person, one sentence at a time. Nothing on the way out is allowed to rewrite, shorten or tidy one of those sentences, so show them as they are rather than your own summary.
-
400— The body was not JSON, or was JSON that is not an object. -
“The body of this request is not JSON. Send a JSON object.” or “Send a JSON object.”
Check the quoting. A shell eating a quote inside -d is the usual cause; put the JSON in a file and send it with --data-binary @file.json if it keeps happening.
-
400— A change with no stamp on it. -
“Read the bundle first and send back the config_version you read, so this change cannot undo somebody else’s.”
Read the bundles, take config_version out of the answer, send it back with the change. For styling the same job is done by based_on, and the sentence is the same with those two words swapped.
-
400— A change to a bundle’s page with no page_version on it. The reason is page_version_missing. -
“Read the bundle first and send back the page_version you read, so this change to its page cannot undo somebody else’s.”
Read the one bundle, take page_version out of the answer, send it back beside config_version. A bundle sent back exactly as read, page and all, needs none, because it changes nothing.
-
400— A value of the wrong kind in a bundle change. -
“`title` must be text, like "Skincare routine". It is the bundle’s one name on the cart line, in your shop’s main language.”, “`min_items` must be a whole number, like 3.”, or “Step 1: `min` must be a whole number, like 1.”
Send the kind it names. A title is one text in the shop’s main language; a step’s title in other languages goes in page.stages, and a tier’s ladder label in page.tiers.
-
400— A call that needs a bundle named and did not name one. -
“Name the bundle: send its id.”
Over HTTP the id is in the address for most calls; the basket simulator takes it in the body, as bundle_id.
-
400— A basket the simulator cannot read. -
“Every line needs a variant_id, like gid://shopify/ProductVariant/123.”, “The quantity for … must be a whole number of at least 1.”, or “A basket can have up to 20 different products.”
Send variant ids in full, not handles and not product ids. Twenty different products is the ceiling; quantities on each line are not capped by it.
-
401— No key, a made-up key, a key for a shop the app cannot reach, or a key revoked this morning. -
“This request needs a working API key. Send it as `Authorization: Bearer <key>`, using a key made in the app that has not been revoked.”
All four get exactly this, on purpose — an outsider is never told which of them it was. Check the header spelling first, then that the key has not been revoked in the app. Every refused key is held for a quarter of a second before it answers, so nobody can learn from the timing which shops have the app.
-
403— A read key asked to change something. -
“This key can read but not change anything. Make a key with write access and use that instead.”
Make a second key with write access. Do not widen the one you have — you cannot, and a key’s access is fixed for its whole life.
-
404— A bundle id that is not on this shop, or an address that does not exist. -
“There is no bundle with that id on this store.” — or, for an address, “There is nothing at that address.” followed by every method and path that does answer.
List the bundles and take the id from there. An id is a slug made from the merchant’s own title; it never changes, and renaming the bundle does not move it.
-
405— The right address, the wrong method. -
“That address does not answer PUT. It answers GET, PATCH, DELETE.” — the method you sent, then the ones that work.
Use the method it names. There is no PUT anywhere in this API, and HEAD and OPTIONS answer nothing either.
-
409— Somebody else changed the shop between your read and your write. -
“Somebody else changed this store’s bundles after you read them, so nothing was saved. Read them again and make your change again.”
Nothing was written. Read again, redo your change on top of what you read, send it again. Do not retry the same body with the old stamp — it will be refused for ever.
-
409— Somebody else changed this bundle’s page between your read and your write. The reason is page_version_stale. -
“Somebody else changed this bundle’s page settings after you read them, so nothing was saved. Read the bundle again and make your change again.”
The same as above, with the bundle’s own page_version: read the bundle again and redo the change.
-
413— A request over 64 KB. -
“That request is too big. The most this API takes in one request is 64 KB.” The assistant connector says the same thing about a message rather than a request.
Send less. This is the one refusal that happens before the key is looked at, so it comes back whether or not you sent one.
-
422— The request was understood and the app would not do it. -
The app’s own refusals, in the same words its Save button gives — for example “This app cannot save "fixed_price" pricing. It can save: percentage, tiered_percentage.”, “Send the pricing as an object with a type on it, like {"type": "percentage", "value": 10}.”, or “Every tier has to save the same way, and each one needs exactly one saving on it.”
Read messages, not message, and show the merchant those sentences rather than your own. Ask the app what it will accept before you guess — that is what the schema is for.
-
422— A collection or a free gift the change adds that this store does not have, or a gift whose product is a draft or archived. -
“Step 1 points at a collection this store doesn’t have (gid://shopify/Collection/1). find_collections finds a collection by its name.”, “Free gift 1: this store has no product variant gid://shopify/ProductVariant/1. find_variants finds one by its name.”, or “Free gift 1: Mini Aftercare Balm is a draft, so shoppers can’t get it. Make it active in Shopify, or choose another gift.”
Look the id up by name rather than typing it: GET /api/v1/collections?query= or GET /api/v1/variants?query=. Only what the change adds is checked, so a bundle whose collection was deleted since can still be changed.
-
422— A bundle whose id is the word “new”. -
“This app reads the id "new" as "make a new bundle", so the bundle that already has that id can only be changed in the admin.”
Nothing, from a terminal. A merchant who called a bundle “New” has one with that id; it can be read through the API and only changed on the screen.
-
500— Something nobody foresaw. -
“Something went wrong at our end, so this request did not finish. Try again in a moment, and check the store before repeating a change.”
Exactly what it says. This one does not promise the shop is unchanged — on a write it might not be — so look before you send the same thing again.
-
503— The shop could not be read or written just now. -
“We couldn’t read this store just now, so nothing was changed. Try again in a moment.”
Wait and repeat it. Nothing was changed.
Two of these are worth knowing before you meet them. A 409 means nothing was written at all — read again, redo your change on what you read, send it again; retrying the same body with the old stamp is refused for ever. And a 413 is decided before the key is looked at, so it answers whether or not you sent one.
The assistant connector from a terminal
The same fifteen calls, spoken as the Model Context Protocol, at one address. Claude Code and other coding tools take one of these, and so does curl — it is a POST carrying one JSON-RPC message. Worth speaking directly when you are working out why a client is unhappy: what curl gets back is exactly what the client got.
Start the conversation
curl -sS -X POST "$MXB/mcp" \
-H "Authorization: Bearer $MXB_KEY" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' \
| python3 -m json.tool
What initialize answers:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2025-06-18",
"capabilities": { "tools": {} },
"serverInfo": { "name": "matrix-bundles", "title": "Matrix Bundles", "version": "1" },
"instructions": "Call describe first. It says what this app can and cannot do, and every limit it enforces, without reading the merchant's store. Reading bundles gives you a config_version and reading the styling gives you a based_on. A change has to carry the one you read, or it is refused. A refusal comes back in the app's own words. Show the merchant those words rather than your own: they are the same sentences the app's screens use, so a question about one has one answer."
}
}
It gives back the revision you ask for if it is 2025-06-18 or 2025-11-25, and 2025-11-25 for anything else. It never names an older one, which would be promising older behaviour, including message batching, which this door does not do. It also speaks 2026-07-28, which has no initialize. The instructions are meant to be read by whatever is driving: call describe first, send back the stamp you read before changing anything, and show the merchant the app’s own words when something is refused.
A real client then sends one message with no id on it. A message with no id is a notification — told, not asked — and gets no answer at all, just an empty 202:
curl -sS -X POST "$MXB/mcp" \
-H "Authorization: Bearer $MXB_KEY" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
What it offers, and calling one
curl -sS -X POST "$MXB/mcp" \
-H "Authorization: Bearer $MXB_KEY" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
| python3 -m json.tool
Fifteen tools for a write key, ten for a read key. Each one is described in the app’s own words, and each carries the shape of what it takes.
curl -sS -X POST "$MXB/mcp" \
-H "Authorization: Bearer $MXB_KEY" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"describe","arguments":{}}}' \
| python3 -m json.tool
An answer comes back as one block of text, and that text is the JSON the HTTP door would have given, indented. There is no second copy of it in a structured field.
And the protocol’s own “are you still there”, which answers with an empty result:
curl -sS -X POST "$MXB/mcp" \
-H "Authorization: Bearer $MXB_KEY" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":4,"method":"ping"}'
Reading a refusal here
A refused tool call answers HTTP 200. That catches people out. The status is about the request — 401 when the key was not accepted, 400 when the message could not be read, 200 when a perfectly good message asked for something the app refused. What was refused is inside:
{
"jsonrpc": "2.0",
"id": 3,
"error": {
"code": -32004,
"message": "every sentence, joined into one line",
"data": {
"errors": [ "the first thing wrong", "the second thing wrong" ],
"kind": "refused",
"http_status": 422
}
}
}
Every refusal is reported twice over: a JSON-RPC number for what was asked, and http_status — the status the HTTP door gives the same refusal — so a program that knows one way in can read the other. The sentences in errors are the same ones the table above quotes.
The refusals that only happen here are about the message rather than what it asked for:
-32600“This address takes a POST holding one JSON-RPC message. There is nothing to read here.” — you sent a GET.
-32700“The body of this request is not JSON.”
-32600“Send one JSON-RPC message: an object with "jsonrpc": "2.0" and a "method".”
-32600“Send one message per request. This door does not take a list of them.” — batching was dropped by this revision of the protocol.
-32600“Every message needs a method.”
-32601“There is no method called "…". There is: initialize, tools/list, tools/call, ping.”
-32602“Name the tool to call, as params.name.” — or, for a tool nobody has, “There is no operation called "…". There is: describe, list_bundles, get_bundle, validate_bundle, simulate_cart, save_bundle, set_bundle_status, delete_bundle, reorder_bundles, get_status, find_collections, find_variants, get_style, validate_strings, save_style.”
Adding it to Claude Code
claude mcp add --transport http matrix-bundles "$MXB/mcp" \
--header "Authorization: Bearer $MXB_KEY"
The key travels as a header. Claude Code can also sign in instead, with no key: leave the header off, then type /mcp inside Claude Code. The connect page has the steps.
Two things to know about that one line. Your shell expands the variable before the command runs, so your history keeps the variable’s name rather than the key — but the tool then writes the header into its own configuration file, in plain text, and it stays there until you remove the server again. And this command was not run against the app when this page was written; it is the syntax the tool’s own help prints, checked, not a connection anybody has made.
The same block as a configuration file, and what to do with assistants that are not at a terminal, are on the connect page.
One change, start to finish
Taking one bundle to 15% off. Five commands, in this order, with the key already in the environment. Nothing is saved until step 4.
# 1 — read the shop's bundles, and keep the stamp.
curl -sS -H "Authorization: Bearer $MXB_KEY" "$MXB/api/v1/bundles"
# 2 — rehearse the change. Nothing is saved by this.
curl -sS -X POST "$MXB/api/v1/bundles/validate" \
-H "Authorization: Bearer $MXB_KEY" \
-H "Content-Type: application/json" \
-d '{ "bundle": { "id": "cream-trio", "pricing": { "type": "percentage", "value": 15 } } }'
# 3 — run the basket a shopper would most likely build, at the price it is now.
curl -sS -X POST "$MXB/api/v1/bundles/simulate" \
-H "Authorization: Bearer $MXB_KEY" \
-H "Content-Type: application/json" \
-d '{ "bundle_id": "cream-trio" }'
# 4 — save it, carrying the stamp from step 1.
curl -sS -X PATCH "$MXB/api/v1/bundles/cream-trio" \
-H "Authorization: Bearer $MXB_KEY" \
-H "Content-Type: application/json" \
-d '{ "pricing": { "type": "percentage", "value": 15 }, "config_version": "THE-STAMP-FROM-STEP-1" }'
# 5 — read it back. The stamp has moved; the sentence says what the rule now is.
curl -sS -H "Authorization: Bearer $MXB_KEY" "$MXB/api/v1/bundles/cream-trio"
Step 2 is the one to keep. It costs one call, it saves nothing, and it hands you the exact sentences the save would have refused with — which is a better thing to read than a 422 after the fact.
If step 4 answers 409, somebody changed the shop between step 1 and step 4. Nothing was saved. Go back to step 1, take the new stamp, and send step 4 again.
How this page was checked
Every command on this page, apart from the four added for schema 1.5 and the two added for schema 1.6, all on 30 September 2026, was pasted into a shell and run against the live app on 24 September 2026. None of them was written from memory and none was copied from another page.
What that proved. That each command is typed correctly and reaches the app: every one of them came back with the app’s own answer, not a “not found”, not a redirect and not a hang. The one that needs no key — the schema read — answered 200. All fifteen that need a key answered 401, with the app’s own sentence about needing a key, which is the right answer: they were run without one, because on that day no key could be made.
What it could not prove. Anything a working key would have got back. Keys can be made now, on the app’s Access keys screen, but no command on this page has yet been run with one, so none of them has been seen to reach past the door. Every answer shown as a shape is read from the app’s own source, and every answer shown as real is marked as real — the 401 body is the live app’s, and nothing else here is.
What came later, and has not been run. The four commands for the setup check, the two searches and the page change were written on 30 September 2026 from the source of the release that added them, schema 1.5, and have not been run against the app. Nor have the two written the same day from the source of schema 1.6: changing the order bundles are tried in, and a basket whose lines carry properties. The same goes for the shapes that follow them. The schema excerpt is the schema 1.6 answer, printed from that release’s own code on the date above it, not fetched from the live app.
Two things could not be checked at all, and are not implied to have been. The “not found” and “wrong method” refusals are decided after the key is checked, so an outsider cannot reach them — they are quoted from the app’s source. And the Claude Code line was not run: it is the syntax that tool’s own help prints.
If you are the first to run any of this with a real key and something here is wrong, tell us and it will be corrected.
Support
Email support@matrixhealthgroup.co.uk and a person will answer. Tell us your shop address, the exact command you ran, and the exact sentence you got back.
Never send us a key. We cannot read yours and we do not want a copy. If you think one has got out, revoke it in the app and make another.
The manual covers installing the app and building a bundle on the screen. The connect page covers the two ways in and how a merchant makes a key.