Stores
The stores on the account and their programmes.
Part of the API reference, version 2026-10. Every address below starts with the base address:
https://app.matrixrewards.co.uk/api/2026-10
Every store on this account
GET /stores — the full address is https://app.matrixrewards.co.uk/api/2026-10/stores.
Needs: the read_programme permission (in the app: “Your programme settings — rates, rewards, rules”). A key with write_programme can call it too.
The answer
The account's stores.
| Field | Type | Always there | What it is |
|---|---|---|---|
data | list of object (Store) | yes | — |
data[].key | string | yes | — |
data[].name | string | yes | — |
data[].platform | string | yes | 'shopify' today; the engine ingests events, so another platform is another producer. |
data[].external_ref | string, or null | yes | That platform's own identifier — a myshopify domain, for instance. |
data[].created_at | string | yes | — |
data[].display_name | string | yes | The store's name as every page shows it: the design's storeName, else the name Shopify has for the shop now, else the name it was installed with. name stays the name it was installed with. |
has_more | boolean | yes | — |
next_cursor | string, or null | yes | — |
Headers on the answer: Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset. What each header means.
Example
curl "https://app.matrixrewards.co.uk/api/2026-10/stores" \
-H "Authorization: Bearer $MATRIX_API_KEY"
The answer:
{
"data": [
{
"key": "example-outlet",
"name": "Example Outlet",
"platform": "shopify",
"external_ref": "example-outlet.myshopify.com",
"created_at": "2026-08-20T14:30:00Z",
"display_name": "Example Outlet"
},
{
"key": "example-store",
"name": "Example Store",
"platform": "shopify",
"external_ref": "example-store.myshopify.com",
"created_at": "2026-08-03T09:12:00Z",
"display_name": "Example Store"
}
],
"has_more": false,
"next_cursor": null
}
Other answers
| Status | What it means | Error pages |
|---|---|---|
| 401 | unauthenticated: no usable API key was presented. Malformed, unknown, revoked and wrong-secret are one answer. | unauthenticated |
| 403 | forbidden_scope: the key does not carry the scope this call needs; required_scope says which. | forbidden_scope |
| 429 | rate_limited: too many requests for this key; bucket says which allowance. Retry-After says how long to wait. | rate_limited |
One store
GET /stores/{store_key} — the full address is https://app.matrixrewards.co.uk/api/2026-10/stores/{store_key}.
Needs: the read_programme permission (in the app: “Your programme settings — rates, rewards, rules”). A key with write_programme can call it too.
Parameters
| Name | In | Required | Type | What it is |
|---|---|---|---|---|
store_key | path | yes | string | — |
The answer
The store.
| Field | Type | Always there | What it is |
|---|---|---|---|
key | string | yes | — |
name | string | yes | — |
platform | string | yes | 'shopify' today; the engine ingests events, so another platform is another producer. |
external_ref | string, or null | yes | That platform's own identifier — a myshopify domain, for instance. |
created_at | string | yes | — |
display_name | string | yes | The store's name as every page shows it: the design's storeName, else the name Shopify has for the shop now, else the name it was installed with. name stays the name it was installed with. |
Headers on the answer: Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset. What each header means.
Example
export STORE='<key of a store from GET /stores>'
curl "https://app.matrixrewards.co.uk/api/2026-10/stores/$STORE" \
-H "Authorization: Bearer $MATRIX_API_KEY"
The answer:
{
"key": "example-store",
"name": "Example Store",
"platform": "shopify",
"external_ref": "example-store.myshopify.com",
"created_at": "2026-08-03T09:12:00Z",
"display_name": "Example Store"
}
Other answers
| Status | What it means | Error pages |
|---|---|---|
| 401 | unauthenticated: no usable API key was presented. Malformed, unknown, revoked and wrong-secret are one answer. | unauthenticated |
| 403 | forbidden_scope: the key does not carry the scope this call needs; required_scope says which. | forbidden_scope |
| 404 | not_found: no such resource on this account — indistinguishable from one that does not exist at all. | not_found |
| 429 | rate_limited: too many requests for this key; bucket says which allowance. Retry-After says how long to wait. | rate_limited |
A store's programme: what is stored, the settings in force, what shoppers are actually paid, and the expiry rule
GET /stores/{store_key}/programme — the full address is https://app.matrixrewards.co.uk/api/2026-10/stores/{store_key}/programme.
Needs: the read_programme permission (in the app: “Your programme settings — rates, rewards, rules”). A key with write_programme can call it too.
Parameters
| Name | In | Required | Type | What it is |
|---|---|---|---|---|
store_key | path | yes | string | — |
The answer
The programme.
| Field | Type | Always there | What it is |
|---|---|---|---|
store_key | string | yes | — |
config | object, open | yes | What the merchant has stored. Empty until they configure it. |
effective | object, or null | yes | The settings in force: what is stored, over built-in defaults, in the engine's own field names. These are SETTINGS, not what shoppers are paid, and three of them can disagree with what they are paid. basePointsPerMajorUnit is a stored setting nothing earns by: orders are paid by the always-on perMajorUnit rules in rules. welcomeBonusPoints is a stored setting nothing pays: the joining bonus is the welcome_bonus rule, and a store with no such rule pays 0. waysToEarn is as stored, and a sentence in it that states the rate, an app-order bonus or the joining bonus may have been written for rules the store no longer has. Do not quote any of the three to shoppers: quote as_paid. bonusesInAppOnly is always present, and false unless set: true means the store's welcome and birthday bonuses are paid only on its own app sign-in, never on a web page. Only Matrix Rewards sets it. spendAnyAmount is always present: whether a shopper may choose how many points to spend, at redeemPointsPerMajorUnit, and the limits on one code. Change it with spend_any_amount (POST /stores/{store_key}/programme). While it is off, the limits shown are the ones kept for when it is switched on again; a minPoints never set is the points worth one unit of the store's currency at redeemPointsPerMajorUnit. |
effective.spendAnyAmount | object | yes | Spend any amount, in the engine's own names. Off (enabled false) at every store until it is switched on. |
effective.spendAnyAmount.enabled | boolean | yes | Whether a shopper may choose how many points to spend. |
effective.spendAnyAmount.minPoints | integer, at least 1 | yes | The fewest points one code may take, before it is rounded up to a whole step. |
effective.spendAnyAmount.maxPoints | integer, at least 1, or null | yes | The most points one code may take, before it is rounded down to a whole step; null for no limit of the store's own (one spend never takes more than 10,000,000 points). |
effective.spendAnyAmount.minSubtotal | string | yes | The smallest order a code can be used on, in the store's currency; "0.00" for any order. |
as_paid | object, or null | yes | What a shopper is actually paid, worked out from the rules the same way the storefront and the account page work it out. These are the figures to show a shopper. Null when the store has no programme, which cannot price an order. |
as_paid.earn_rate_per_major_unit | number, at least 0 | yes | Points an order pays per whole unit of the store's currency: the sum of the rules that apply to every order. Conditional bonuses (an app-order bonus, a campaign window, a product rule) are left out, because they hold only for some orders. 0 means nothing pays per unit; say nothing about a rate rather than "earn 0". |
as_paid.earn_rates_per_major_unit | list of number, at least 0 | yes | The same rules one by one. To price an amount, round each rate times the amount DOWN on its own and add the results: that is how an order is paid, and one rounding over the sum can promise a point more than the order pays. |
as_paid.welcome_bonus_points | integer, at least 0 | yes | The joining bonus a new shopper is paid, once. 0 when the store pays none, whatever effective.welcomeBonusPoints says. At a store whose welcome and birthday bonuses are paid only in its own app (the programme's effective.bonusesInAppOnly), it is paid only on the store's own app sign-in, never on a web page: do not offer it there as a web sign-up bonus. The platform template's own welcome sentence in ways_to_earn says so. |
as_paid.ways_to_earn | list of object | yes | effective.waysToEarn as a shopper is shown it. The base-rate sentence the app wrote is rebuilt from earn_rate_per_major_unit, and left out when that is 0. On a store built from one of the platform's templates, the template's own sentences about the rate, the app-order bonus and the joining bonus are served as written while the rules still pay what they say, corrected when a figure has moved, and left out when nothing pays them. Entries the merchant wrote are unchanged. |
as_paid.ways_to_earn[].id | string | yes | — |
as_paid.ways_to_earn[].label | string | yes | — |
as_paid.ways_to_earn[].detail | string | yes | — |
appearance | object, open | yes | Resolved colours, wording and toggles for the shopper-facing surfaces. |
expiry | object, or null | yes | — |
expiry.period_months | integer | yes | At least 12 — the platform's floor. |
expiry.effective_from | string | yes | — |
version | string, matching ^[0-9a-f]{32}$ | yes | Which stored programme this is: it changes whenever the store's programme settings change (not its look or expiry), so two reads with the same version saw the same programme. Send it as if_version with a programme change (POST /stores/{store_key}/programme) and the change is refused, 409 settings_changed, if the programme has changed since you read it. The embedded admin's save checks the same version. |
An open object has no fixed set of fields: it is sent as the platform holds it, in its own field names.
Headers on the answer: Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset. What each header means.
Example
export STORE='<key of a store from GET /stores>'
curl "https://app.matrixrewards.co.uk/api/2026-10/stores/$STORE/programme" \
-H "Authorization: Bearer $MATRIX_API_KEY"
The answer:
{
"store_key": "example-store",
"config": {
"rules": [
{
"id": "base_earn",
"award": {
"type": "perMajorUnit",
"pointsPerUnit": 1
},
"event": "order_paid",
"reason": "Points earned on your order",
"condition": {
"type": "always"
}
}
],
"rewards": [
{
"id": "reward_5",
"costPoints": 500,
"valueLabel": "5.00 GBP off",
"minSubtotal": "5.00",
"valueAmount": "5.00"
}
],
"currency": "GBP",
"timezone": "Europe/London",
"waysToEarn": [
{
"id": "orders",
"label": "Shop online",
"detail": "Earn 1 point for every £1 you spend"
}
],
"appChannelValue": "",
"loyaltyStartDate": "2026-08-03",
"appOrderMultiplier": 1,
"welcomeBonusPoints": 0,
"appChannelAttribute": "_channel",
"basePointsPerMajorUnit": 1,
"redeemPointsPerMajorUnit": 100
},
"effective": {
"currency": "GBP",
"timezone": "Europe/London",
"basePointsPerMajorUnit": 1,
"appOrderMultiplier": 1,
"welcomeBonusPoints": 0,
"redeemPointsPerMajorUnit": 100,
"appChannelAttribute": "_channel",
"appChannelValue": "",
"loyaltyStartDate": "2026-08-03",
"rewards": [
{
"id": "reward_5",
"costPoints": 500,
"valueLabel": "5.00 GBP off",
"minSubtotal": "5.00",
"valueAmount": "5.00"
}
],
"rules": [
{
"id": "base_earn",
"award": {
"type": "perMajorUnit",
"pointsPerUnit": 1
},
"event": "order_paid",
"reason": "Points earned on your order",
"condition": {
"type": "always"
}
}
],
"waysToEarn": [
{
"id": "orders",
"label": "Shop online",
"detail": "Earn 1 point for every £1 you spend"
}
],
"referral": {
"enabled": true,
"friendPoints": 300,
"advocatePoints": 200,
"qualifyingMinAmount": "15.00",
"claimExpiryDays": 30,
"advocateCapFriends": 10
},
"bonusesInAppOnly": false,
"spendAnyAmount": {
"enabled": false,
"minPoints": 100,
"maxPoints": null,
"minSubtotal": "0.00"
}
},
"as_paid": {
"earn_rate_per_major_unit": 1,
"earn_rates_per_major_unit": [
1
],
"welcome_bonus_points": 0,
"ways_to_earn": [
{
"id": "orders",
"label": "Shop online",
"detail": "Earn 1 point for every £1 you spend"
}
]
},
"appearance": {
"programmeName": "Rewards",
"pointsName": "point",
"pointsNamePlural": "points",
"heading": "",
"storeName": "",
"signedOutPrompt": "Sign in to see your points.",
"rewardsHeading": "What you can get",
"earnHeading": "Ways to earn",
"activityHeading": "Recent activity",
"accent": null,
"cornerRadius": null,
"density": "comfortable",
"textScale": "normal",
"fontFamily": "theme",
"showRewards": true,
"showWaysToEarn": true,
"showActivity": true,
"showExpiry": true,
"showSharedWallet": true,
"showProductPoints": true,
"showRewardsSite": false,
"termsUrl": null,
"otherShops": {}
},
"expiry": null,
"version": "3f1c9a2b7d4e5f60718293a4b5c6d7e8"
}
Other answers
| Status | What it means | Error pages |
|---|---|---|
| 401 | unauthenticated: no usable API key was presented. Malformed, unknown, revoked and wrong-secret are one answer. | unauthenticated |
| 403 | forbidden_scope: the key does not carry the scope this call needs; required_scope says which. | forbidden_scope |
| 404 | not_found: no such resource on this account — indistinguishable from one that does not exist at all. | not_found |
| 429 | rate_limited: too many requests for this key; bucket says which allowance. Retry-After says how long to wait. | rate_limited |
Change the earn rate, the bonuses, what a point is worth, referrals, spending any amount, the time zone, the ways to earn, or when points expire
POST /stores/{store_key}/programme — the full address is https://app.matrixrewards.co.uk/api/2026-10/stores/{store_key}/programme.
Needs: the write_programme permission (in the app: “Change your programme: the earn rate, the joining and birthday bonuses, what a point is worth, when points expire, the rewards and the offers. Every change is previewable and logged”).
Plain fields, not rules: the rules that pay them are built by the same code the Programme screen in your Shopify admin runs, and every other rule (offers, rewards, anything set up by hand) is carried exactly as it is. The change is built over one reading of the programme, so a change made elsewhere in between is refused (409 settings_changed), never undone. A preview (dry_run=true) makes the change in a transaction, reads the programme back and rolls it back: it shows what three example orders earn before and after, every figure the change moves before and after in this call's own field names (before_after) and, for a point-value change, what the points shoppers hold are worth. At most 100 changes per account through the API in any 24 hours. Spending any amount (spend_any_amount) lets a shopper choose how many points to spend, for a code worth exactly that at the store's point value; it is off at every store until it is switched on, and the preview shows its terms before and after. The currency cannot be changed here. Written to the audit log in the same transaction as the change: if the entry cannot be written, the change is not made.
Parameters
| Name | In | Required | Type | What it is |
|---|---|---|---|---|
store_key | path | yes | string | — |
Idempotency-Key | header | yes | string, matching ^[A-Za-z0-9._:-]{1,100}$ | Required on both writes, a points change and a cancel. Pick a new one for each new change and send the same one to retry it. For 24 hours from its first use, a key has three answers: the same request again gets the first answer back, byte for byte, with Idempotency-Replayed: true; a different request under the key is 422 idempotency_key_reused; and while the first request is still running, 409 idempotency_key_in_use with Retry-After. A 5xx does not use the key up: retry with the same one. For a points change the key is also the adjustment's id in the ledger, so the same change is never booked twice, whichever of the account's keys sends it, and even after the 24 hours. |
dry_run | query | no | boolean | true previews the change: every check the real call makes runs, the answer has the status the real call would have, and nothing changes — except one dry_run entry in the audit log, because a preview shows the member's balance. When the change would happen, the preview carries a confirmation for exactly this request. It is the only query parameter a write takes: any other, a misspelling such as dryRun included, is refused with 400 invalid_request naming it in param, and nothing changes. |
Matrix-Confirmation | header | no | string, up to 1,024 characters | The confirmation.token a dry run of this exact request returned. Required for keys tagged for an assistant, except on a replay: a request whose first answer is still kept, or a points change that is already made; checked whenever it is sent. It covers one request — the same key, member, body and Idempotency-Key — for 10 minutes. It proves the change was previewed first; it cannot prove a person saw the preview. |
Request body
Sent as application/json.
| Field | Type | Required | What it is |
|---|---|---|---|
points_per_currency_unit | number, from 0 to 1,000 | no | The everyday earn rate: points for every 1 of the store's currency an order pays, with at most 2 decimal places. 0 stops it. |
welcome_bonus_points | integer, from 0 to 100,000 | no | Points a shopper gets for joining. 0 stops the bonus. |
birthday_bonus_points | integer, from 0 to 100,000 | no | Points a shopper gets on their birthday. 0 stops the bonus. |
point_value | object | no | What a point is worth: points points are worth amount of the store's currency. points divided by amount must be a whole number. |
point_value.points | integer, from 1 to 100,000 | yes | — |
point_value.amount | string, matching ^\d{1,7}(?:\.\d{1,2})?$ | yes | — |
referral | object | no | What a referral pays at this store, and whether it runs. Only the terms sent change: { "enabled": false } stops referrals and keeps the numbers. A friend who has already accepted an invitation is paid on the terms they were shown. |
referral.enabled | boolean | no | true runs referrals at this store; false stops them. |
referral.friend_points | integer, from 1 to 1,000,000 | no | What the invited friend is paid, in points. |
referral.advocate_points | integer, from 1 to 1,000,000 | no | What the shopper who invited them is paid, in points. |
referral.qualifying_min_amount | string, matching ^\d{1,9}(?:\.\d{1,3})?$ | no | The smallest first order that pays the referral, in the store's currency; more than zero. |
referral.claim_expiry_days | integer, from 14 to 365 | no | Days the friend has to place that order. |
referral.advocate_cap_friends | integer, from 1 to 100 | no | The most friends one shopper is paid for in any rolling window (referral_advocate_cap_window_days). |
spend_any_amount | object | no | Whether a shopper may choose how many points to spend, and get a discount code worth exactly that at the store's point value (point_value: there is no second rate), and the limits on one code. Off at every store until it is switched on, here or in the app. Only the terms sent change: { "enabled": false } switches it off and keeps the limits for when it is switched on again. A shopper can spend only whole steps, a number of points worth a whole number of the currency's smallest unit, so the fewest points are rounded up to a whole step and the most down, and there must be something between them: otherwise the change is refused, invalid_request with problem below_minimum on spend_any_amount.max_points, and so is a point_value change that would leave nothing between them. A code comes off the items of an order, never delivery, and anything over their total is not kept. Codes already issued keep what they were issued for, and the store's rewards are unchanged beside it. An assistant's key may switch it off or narrow it, never switch it on. |
spend_any_amount.enabled | boolean | no | true lets shoppers choose how many points to spend; false stops it. Off until it is switched on. |
spend_any_amount.min_points | integer, from 1 to 10,000,000 | no | The fewest points one code may take, rounded up to a whole step. Until it is set, the points worth one whole unit of the store's currency. |
spend_any_amount.max_points | integer, from 1 to 10,000,000, or null | no | The most points one code may take, rounded down to a whole step; null (the default) for no limit of the store's own (one spend never takes more than 10,000,000 points). |
spend_any_amount.min_subtotal | string, matching ^\d{1,9}(?:\.\d{1,3})?$ | no | The smallest order a code can be used on, in the store's currency and no finer than its smallest unit (two decimal places for pounds); "0.00" (the default) for any order. |
timezone | string | no | The store's time zone, by its IANA name, like "Europe/London" or "America/New_York": the one its days are reckoned in (a shopper's birthday, a campaign's first and last day). Set from the shop when the app is installed. An unknown name is refused (invalid_request on timezone); the zone already set changes nothing. Campaigns already set keep their exact start and end times, and their days are then read in the new zone. |
ways_to_earn | list of object | no | The lines a shopper reads under "Ways to earn", in order, at most 50 (ways_to_earn_max), replacing the store's list whole; [] for none of its own. Each id once. The platform still adds a "Join" line for a joining bonus and a "Birthday" line for a birthday bonus when the list has no entry with the id join or birthday; a list with an id starting earn_ is read as one of the platform's own templates, and gets neither. A line with the id orders saying what every order earns in the platform's own words is rewritten whenever the rate moves. The preview shows the list before and after as a shopper will read it (before_after.ways_to_earn). The same list as now changes nothing. |
ways_to_earn[].id | string, up to 64 characters, at least 1 character | yes | — |
ways_to_earn[].label | string, up to 120 characters, at least 1 character | yes | The line's heading, like "Shop online". |
ways_to_earn[].detail | string, up to 500 characters, at least 1 character | yes | What it pays, in words a shopper reads. Say only what the programme pays. |
expiry | object | no | Points expire this many months after a shopper's last activity, from this instant. It can never reach back before what shoppers were told (retroactive_change). |
expiry.period_months | integer, from 12 to 1,200 | yes | — |
expiry.effective_from | string, matching ^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$ | yes | A UTC instant on a date that exists, in the year 9898 or earlier (expiry_max_effective_year): a later start would put shoppers' expiry dates past four-digit years. |
if_version | string, matching ^[0-9a-f]{32}$ | no | The version from a programme read. If the programme has changed since, nothing is changed (409 settings_changed). |
The answer
The change, or with dry_run=true the preview of it. A request already answered is answered again, byte for byte (Idempotency-Replayed).
One shape: ProgrammeChange.
| Field | Type | Always there | What it is |
|---|---|---|---|
object | always programme_change | yes | — |
store_key | string | yes | — |
version | string, matching ^[0-9a-f]{32}$ | yes | The programme's version after the change. |
changed | list of string | yes | The settings that changed, in the engine's names. Empty when the change asked for what was already set. |
as_paid | object, or null | yes | What a shopper is actually paid, worked out from the rules the same way the storefront and the account page work it out. These are the figures to show a shopper. Null when the store has no programme, which cannot price an order. |
as_paid.earn_rate_per_major_unit | number, at least 0 | yes | Points an order pays per whole unit of the store's currency: the sum of the rules that apply to every order. Conditional bonuses (an app-order bonus, a campaign window, a product rule) are left out, because they hold only for some orders. 0 means nothing pays per unit; say nothing about a rate rather than "earn 0". |
as_paid.earn_rates_per_major_unit | list of number, at least 0 | yes | The same rules one by one. To price an amount, round each rate times the amount DOWN on its own and add the results: that is how an order is paid, and one rounding over the sum can promise a point more than the order pays. |
as_paid.welcome_bonus_points | integer, at least 0 | yes | The joining bonus a new shopper is paid, once. 0 when the store pays none, whatever effective.welcomeBonusPoints says. At a store whose welcome and birthday bonuses are paid only in its own app (the programme's effective.bonusesInAppOnly), it is paid only on the store's own app sign-in, never on a web page: do not offer it there as a web sign-up bonus. The platform template's own welcome sentence in ways_to_earn says so. |
as_paid.ways_to_earn | list of object | yes | effective.waysToEarn as a shopper is shown it. The base-rate sentence the app wrote is rebuilt from earn_rate_per_major_unit, and left out when that is 0. On a store built from one of the platform's templates, the template's own sentences about the rate, the app-order bonus and the joining bonus are served as written while the rules still pay what they say, corrected when a figure has moved, and left out when nothing pays them. Entries the merchant wrote are unchanged. |
as_paid.ways_to_earn[].id | string | yes | — |
as_paid.ways_to_earn[].label | string | yes | — |
as_paid.ways_to_earn[].detail | string | yes | — |
audit_id | string, or null | yes | — |
One shape: ProgrammeChangePreview.
| Field | Type | Always there | What it is |
|---|---|---|---|
object | always programme_change_preview | yes | — |
dry_run | always true | yes | — |
outcome | one of would_apply, no_change | yes | — |
store_key | string | yes | — |
changed | list of string | yes | The settings that would change, in the engine's names. What each moves from and to is before_after. |
before_after | object (PreviewBeforeAfter) | yes | Every figure the change moves, before and after, in this API's own write field names, worked out from the programme as it would price orders before and after (the same way the storefront works them out). Only the ones that differ are here, so {} means none moves. points_per_currency_unit is the everyday rate an order pays (a running campaign or other offer left out); welcome_bonus_points and birthday_bonus_points are 0 when none is paid; point_value is how many points are worth one unit of the currency; ways_to_earn is the list as a shopper reads it. On a reward's call, reward is the reward itself: null before an add, null after a removal. |
before_after.points_per_currency_unit | object | no | — |
before_after.points_per_currency_unit.before | number, at least 0 | yes | — |
before_after.points_per_currency_unit.after | number, at least 0 | yes | — |
before_after.welcome_bonus_points | object | no | — |
before_after.welcome_bonus_points.before | integer, at least 0 | yes | — |
before_after.welcome_bonus_points.after | integer, at least 0 | yes | — |
before_after.birthday_bonus_points | object | no | — |
before_after.birthday_bonus_points.before | integer, at least 0 | yes | — |
before_after.birthday_bonus_points.after | integer, at least 0 | yes | — |
before_after.point_value | object | no | — |
before_after.point_value.before | object | yes | — |
before_after.point_value.before.points | integer, at least 1 | yes | — |
before_after.point_value.before.amount | always 1.00 | yes | — |
before_after.point_value.after | object | yes | — |
before_after.point_value.after.points | integer, at least 1 | yes | — |
before_after.point_value.after.amount | always 1.00 | yes | — |
before_after.timezone | object | no | — |
before_after.timezone.before | string | yes | — |
before_after.timezone.after | string | yes | — |
before_after.ways_to_earn | object | no | — |
before_after.ways_to_earn.before | list of object | yes | — |
before_after.ways_to_earn.before[].id | string | yes | — |
before_after.ways_to_earn.before[].label | string | yes | — |
before_after.ways_to_earn.before[].detail | string | yes | — |
before_after.ways_to_earn.after | list of object | yes | — |
before_after.ways_to_earn.after[].id | string | yes | — |
before_after.ways_to_earn.after[].label | string | yes | — |
before_after.ways_to_earn.after[].detail | string | yes | — |
before_after.referral | object | no | — |
before_after.referral.before | any, or null or object | yes | — |
before_after.referral.after | any, or null or object | yes | — |
before_after.spend_any_amount | object | no | — |
before_after.spend_any_amount.before | any, or null or object | yes | — |
before_after.spend_any_amount.after | any, or null or object | yes | — |
before_after.reward | object | no | — |
before_after.reward.before | any, or null or object (ApiReward) | yes | — |
before_after.reward.after | any, or null or object (ApiReward) | yes | — |
currency | string, or null | yes | — |
examples | list of object | yes | What a plain order of each size earns now, before and after the change, under every offer running now. |
examples[].order_amount | string | yes | — |
examples[].points_before | integer | yes | — |
examples[].points_after | integer | yes | — |
point_value | object | yes | — |
point_value.points_per_currency_unit_before | integer, or null | yes | — |
point_value.points_per_currency_unit_after | integer, or null | yes | — |
liability | any, or null or object or object | yes | When the point value changes: what the points shoppers hold are worth before and after. Null when it does not change; unavailable when the account has more than one store. |
version_after | string, matching ^[0-9a-f]{32}$, or null | yes | The version the programme would have. |
confirmation | any, or null or object | yes | For a key tagged for an assistant: send token back as the Matrix-Confirmation header with the same request. Null when nothing would change. |
audit_id | string, or null | yes | — |
referral | any, or null or object | yes | When the change sends referral: the store's referral terms before and after. Null otherwise. |
spend_any_amount | any, or null or object | yes | When the change sends spend_any_amount: its terms before and after, as stored, so the limits kept while it is off are shown. Null otherwise. |
Headers on the answer: Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset; and, when it applies, Idempotency-Replayed. What each header means.
Example
export STORE='<key of a store from GET /stores>'
curl -X POST "https://app.matrixrewards.co.uk/api/2026-10/stores/$STORE/programme" \
-H "Authorization: Bearer $MATRIX_API_KEY" \
-H "Idempotency-Key: programme-2026-09-16-0001" \
-H "Content-Type: application/json" \
-d '{"points_per_currency_unit": 2}'
The change, made:
{
"object": "programme_change",
"store_key": "example-store",
"version": "9b2e4c1d7a8f60519283a4b5c6d7e8f0",
"changed": [
"basePointsPerMajorUnit",
"rules"
],
"as_paid": {
"earn_rate_per_major_unit": 2,
"earn_rates_per_major_unit": [
2
],
"welcome_bonus_points": 0,
"ways_to_earn": []
},
"audit_id": "0d1e2f30-4a5b-4c6d-8e7f-00000000000e"
}
The same request with ?dry_run=true: nothing changes:
{
"object": "programme_change_preview",
"dry_run": true,
"outcome": "would_apply",
"store_key": "example-store",
"changed": [
"basePointsPerMajorUnit",
"rules"
],
"before_after": {
"points_per_currency_unit": {
"before": 1,
"after": 2
}
},
"currency": "GBP",
"examples": [
{
"order_amount": "10.00",
"points_before": 10,
"points_after": 20
},
{
"order_amount": "50.00",
"points_before": 50,
"points_after": 100
},
{
"order_amount": "100.00",
"points_before": 100,
"points_after": 200
}
],
"point_value": {
"points_per_currency_unit_before": 100,
"points_per_currency_unit_after": 100
},
"liability": null,
"referral": null,
"spend_any_amount": null,
"version_after": "9b2e4c1d7a8f60519283a4b5c6d7e8f0",
"confirmation": {
"token": "mct_EXAMPLE.EXAMPLE",
"expires_at": "2026-09-16T10:10:00.000Z"
},
"audit_id": "0d1e2f30-4a5b-4c6d-8e7f-00000000000f"
}
Other answers
| Status | What it means | Error pages |
|---|---|---|
| 400 | invalid_request: a parameter or the body could not be read; param names it. | invalid_request |
| 401 | unauthenticated: no usable API key was presented. Malformed, unknown, revoked and wrong-secret are one answer. | unauthenticated |
| 403 | forbidden_scope: the key does not hold write_programme (required_scope says so). test_mode_key: a test key can preview a change and never make one. assistant_limit: the key is an assistant's (every Claude connection, and a key made for an assistant), the merchant has not lifted its fixed limits (/me key.limits_lifted; only a person can lift them, on the app's API keys screen), and the change moves the store's money further than one step. It is measured from the programme as it stood before the assistant's changes began, so steps add up; that starting point holds until assistant_window_hours pass with no assistant change, and a change by the owner to what the programme pays starts afresh (a new label or reason does not; nor does a change by an assistant key whose limits are lifted). What a point is worth, the currency and when points expire never change. What an order earns at most doubles: a new offer counts as paying every order, against what every order earned; and the everyday rate, which still prices some refunds (an API refund that names no order, and an order paid before amounts were recorded), is not lowered to fund an offer (then the figures are the everyday rate's own, least_allowed is the least it may be for every order to earn what it did, up to the field's two decimal places — null when no rate within its own step would do — and on an offer's route no param is named). Every other figure that pays shoppers at most doubles, one at zero stays there, and referrals that are off stay off. Spending any amount that is off stays off; while it is on, the fewest points one code may take and the smallest order it can be used on at most halve, and the most points at most double (a limit cannot be removed); switching it off is never limited. An offer may only narrow who and when it pays. A reward at most doubles what it gives for each point, keeps its kind, and its smallest order at most halves; a new reward stays within one step of a reward of its kind. The preview is refused the same way. problem says which limit; before, asked and most_allowed or least_allowed give the figure in the unit of param, and sending that limit back is accepted. | forbidden_scope, test_mode_key, assistant_limit |
| 404 | not_found: no such member on this account, or (cancelling a code) the member holds no code with that ref. | not_found |
| 409 | idempotency_key_in_use: a request with this key is still running (Retry-After). settings_changed: the programme changed after it was read — by if_version, or by the reading this change was built over — so nothing was changed; read it again and resend. | idempotency_key_in_use, settings_changed |
| 413 | payload_too_large: the body is over 4,096 bytes. | payload_too_large |
| 422 | idempotency_key_reused: this Idempotency-Key was used for a different request in the last 24 hours. retroactive_change: the expiry change would expire points sooner than the rule shoppers have already been told about, or switch expiry on from a date already past (problem, and the current rule when there is one). | idempotency_key_reused, retroactive_change |
| 428 | confirmation_required: this key is tagged for an assistant; preview with dry_run=true and send the returned confirmation. confirmation_invalid: the confirmation is not for this request, has expired, or is not one we issued (reason). | confirmation_required, confirmation_invalid |
| 429 | rate_limited: too many requests for this key (Retry-After). programme_write_limit: this account has made 100 programme changes through the API in the last 24 hours (limit, used); nothing was changed. | rate_limited, programme_write_limit |
| 503 | change_not_recorded (or access_not_recorded for a preview or a replay): the audit log could not be written, so nothing was changed or returned. Retry with the same Idempotency-Key. | change_not_recorded, access_not_recorded |
The rewards a shopper can spend points on at this store
GET /stores/{store_key}/programme/rewards — the full address is https://app.matrixrewards.co.uk/api/2026-10/stores/{store_key}/programme/rewards.
Needs: the read_programme permission (in the app: “Your programme settings — rates, rewards, rules”). A key with write_programme can call it too.
Parameters
| Name | In | Required | Type | What it is |
|---|---|---|---|---|
store_key | path | yes | string | — |
The answer
The effective rewards list.
| Field | Type | Always there | What it is |
|---|---|---|---|
data | list of object | yes | — |
data[].id | string | yes | The reward's id: what changeReward and removeReward take as reward_id. |
has_more | boolean | yes | — |
next_cursor | string, or null | yes | — |
version | string, matching ^[0-9a-f]{32}$ | yes | Which stored programme this is: it changes whenever the store's programme settings change (not its look or expiry), so two reads with the same version saw the same programme. Send it as if_version with a programme change (POST /stores/{store_key}/programme) and the change is refused, 409 settings_changed, if the programme has changed since you read it. The embedded admin's save checks the same version. |
Headers on the answer: Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset. What each header means.
Example
export STORE='<key of a store from GET /stores>'
curl "https://app.matrixrewards.co.uk/api/2026-10/stores/$STORE/programme/rewards" \
-H "Authorization: Bearer $MATRIX_API_KEY"
The answer:
{
"data": [
{
"id": "reward_5",
"costPoints": 500,
"valueLabel": "5.00 GBP off",
"minSubtotal": "5.00",
"valueAmount": "5.00"
}
],
"has_more": false,
"next_cursor": null,
"version": "3f1c9a2b7d4e5f60718293a4b5c6d7e8"
}
Other answers
| Status | What it means | Error pages |
|---|---|---|
| 401 | unauthenticated: no usable API key was presented. Malformed, unknown, revoked and wrong-secret are one answer. | unauthenticated |
| 403 | forbidden_scope: the key does not carry the scope this call needs; required_scope says which. | forbidden_scope |
| 404 | not_found: no such resource on this account — indistinguishable from one that does not exist at all. | not_found |
| 429 | rate_limited: too many requests for this key; bucket says which allowance. Retry-After says how long to wait. | rate_limited |
Add a reward shoppers can spend points on
POST /stores/{store_key}/programme/rewards — the full address is https://app.matrixrewards.co.uk/api/2026-10/stores/{store_key}/programme/rewards.
Needs: the write_programme permission (in the app: “Change your programme: the earn rate, the joining and birthday bonuses, what a point is worth, when points expire, the rewards and the offers. Every change is previewable and logged”).
The platform names the reward reward_ and the next whole number, whatever its label says, never a number a reward or a code has already had. Every other reward, and every earn rule, is carried exactly as it is. The preview warns, and never refuses, when the reward is worth more than 10 times what its points are worth at the store's point value, or more than the smallest order it can be used on. Counts toward the 100 programme changes a day through the API.
Parameters
| Name | In | Required | Type | What it is |
|---|---|---|---|---|
store_key | path | yes | string | — |
Idempotency-Key | header | yes | string, matching ^[A-Za-z0-9._:-]{1,100}$ | Required on both writes, a points change and a cancel. Pick a new one for each new change and send the same one to retry it. For 24 hours from its first use, a key has three answers: the same request again gets the first answer back, byte for byte, with Idempotency-Replayed: true; a different request under the key is 422 idempotency_key_reused; and while the first request is still running, 409 idempotency_key_in_use with Retry-After. A 5xx does not use the key up: retry with the same one. For a points change the key is also the adjustment's id in the ledger, so the same change is never booked twice, whichever of the account's keys sends it, and even after the 24 hours. |
dry_run | query | no | boolean | true previews the change: every check the real call makes runs, the answer has the status the real call would have, and nothing changes — except one dry_run entry in the audit log, because a preview shows the member's balance. When the change would happen, the preview carries a confirmation for exactly this request. It is the only query parameter a write takes: any other, a misspelling such as dryRun included, is refused with 400 invalid_request naming it in param, and nothing changes. |
Matrix-Confirmation | header | no | string, up to 1,024 characters | The confirmation.token a dry run of this exact request returned. Required for keys tagged for an assistant, except on a replay: a request whose first answer is still kept, or a points change that is already made; checked whenever it is sent. It covers one request — the same key, member, body and Idempotency-Key — for 10 minutes. It proves the change was previewed first; it cannot prove a person saw the preview. |
Request body
Sent as application/json.
| Field | Type | Required | What it is |
|---|---|---|---|
kind | one of amount, percentage, free_shipping | no | What the reward gives: an amount off the order (the default), a percentage off its goods, or free shipping. |
cost_points | integer, from 1 to 10,000,000 | yes | What the reward costs, in points. |
value_amount | string, matching ^\d{1,7}(?:\.\d{1,2})?$ | no | For an amount reward: how much it takes off an order, in the store's currency, more than zero. Not sent for the other kinds. |
percent_off | integer, from 1 to 100 | no | For a percentage reward: the percentage off, a whole number. |
min_subtotal | string, matching ^\d{1,7}(?:\.\d{1,2})?$ | no | The smallest order it can be used on; "0.00" (the default) for any order. |
label | string, up to 64 characters, at least 1 character | no | What the shopper sees. Left out on an add, it is the value in the store's currency, like "£5.00 off". |
if_version | string, matching ^[0-9a-f]{32}$ | no | The version from a programme read. If the programme has changed since, nothing is changed (409 settings_changed). |
The answer
The change, or with dry_run=true the preview of it. A request already answered is answered again, byte for byte (Idempotency-Replayed).
One shape: RewardResult.
| Field | Type | Always there | What it is |
|---|---|---|---|
object | always reward | yes | — |
reward | object (ApiReward) | yes | — |
reward.id | string | yes | — |
reward.kind | one of amount, percentage, free_shipping | yes | What the reward gives: an amount off the order (the default), a percentage off its goods, or free shipping. |
reward.cost_points | integer | yes | — |
reward.value_amount | string | yes | For an amount reward, the amount off; "0.00" for the other kinds. |
reward.percent_off | integer, or null | yes | For a percentage reward, the percentage off; otherwise null. |
reward.min_subtotal | string | yes | — |
reward.label | string | yes | — |
warnings | list of one of reward_worth_more_than_points, reward_worth_more_than_minimum_order | yes | What may be a mistake, never a refusal: reward_worth_more_than_points (worth more than 10 times what its points are worth at the store's point value) and reward_worth_more_than_minimum_order (worth more than the smallest order it can be used on; a reward with no minimum can be used on any order, so it always is). Only an amount reward is weighed: a percentage or free shipping is worth what the order makes it. |
store_key | string | yes | — |
version | string, matching ^[0-9a-f]{32}$ | yes | The programme's version after the change. |
changed | list of string | yes | The settings that changed, in the engine's names. Empty when the change asked for what was already set. |
as_paid | object, or null | yes | What a shopper is actually paid, worked out from the rules the same way the storefront and the account page work it out. These are the figures to show a shopper. Null when the store has no programme, which cannot price an order. |
as_paid.earn_rate_per_major_unit | number, at least 0 | yes | Points an order pays per whole unit of the store's currency: the sum of the rules that apply to every order. Conditional bonuses (an app-order bonus, a campaign window, a product rule) are left out, because they hold only for some orders. 0 means nothing pays per unit; say nothing about a rate rather than "earn 0". |
as_paid.earn_rates_per_major_unit | list of number, at least 0 | yes | The same rules one by one. To price an amount, round each rate times the amount DOWN on its own and add the results: that is how an order is paid, and one rounding over the sum can promise a point more than the order pays. |
as_paid.welcome_bonus_points | integer, at least 0 | yes | The joining bonus a new shopper is paid, once. 0 when the store pays none, whatever effective.welcomeBonusPoints says. At a store whose welcome and birthday bonuses are paid only in its own app (the programme's effective.bonusesInAppOnly), it is paid only on the store's own app sign-in, never on a web page: do not offer it there as a web sign-up bonus. The platform template's own welcome sentence in ways_to_earn says so. |
as_paid.ways_to_earn | list of object | yes | effective.waysToEarn as a shopper is shown it. The base-rate sentence the app wrote is rebuilt from earn_rate_per_major_unit, and left out when that is 0. On a store built from one of the platform's templates, the template's own sentences about the rate, the app-order bonus and the joining bonus are served as written while the rules still pay what they say, corrected when a figure has moved, and left out when nothing pays them. Entries the merchant wrote are unchanged. |
as_paid.ways_to_earn[].id | string | yes | — |
as_paid.ways_to_earn[].label | string | yes | — |
as_paid.ways_to_earn[].detail | string | yes | — |
audit_id | string, or null | yes | — |
One shape: RewardPreview.
| Field | Type | Always there | What it is |
|---|---|---|---|
object | always reward_preview | yes | — |
reward | object (ApiReward) | yes | — |
reward.id | string | yes | — |
reward.kind | one of amount, percentage, free_shipping | yes | What the reward gives: an amount off the order (the default), a percentage off its goods, or free shipping. |
reward.cost_points | integer | yes | — |
reward.value_amount | string | yes | For an amount reward, the amount off; "0.00" for the other kinds. |
reward.percent_off | integer, or null | yes | For a percentage reward, the percentage off; otherwise null. |
reward.min_subtotal | string | yes | — |
reward.label | string | yes | — |
warnings | list of one of reward_worth_more_than_points, reward_worth_more_than_minimum_order | yes | What may be a mistake, never a refusal: reward_worth_more_than_points (worth more than 10 times what its points are worth at the store's point value) and reward_worth_more_than_minimum_order (worth more than the smallest order it can be used on; a reward with no minimum can be used on any order, so it always is). Only an amount reward is weighed: a percentage or free shipping is worth what the order makes it. |
dry_run | always true | yes | — |
outcome | one of would_apply, no_change | yes | — |
store_key | string | yes | — |
changed | list of string | yes | The settings that would change, in the engine's names. What each moves from and to is before_after. |
before_after | object (PreviewBeforeAfter) | yes | Every figure the change moves, before and after, in this API's own write field names, worked out from the programme as it would price orders before and after (the same way the storefront works them out). Only the ones that differ are here, so {} means none moves. points_per_currency_unit is the everyday rate an order pays (a running campaign or other offer left out); welcome_bonus_points and birthday_bonus_points are 0 when none is paid; point_value is how many points are worth one unit of the currency; ways_to_earn is the list as a shopper reads it. On a reward's call, reward is the reward itself: null before an add, null after a removal. |
before_after.points_per_currency_unit | object | no | — |
before_after.points_per_currency_unit.before | number, at least 0 | yes | — |
before_after.points_per_currency_unit.after | number, at least 0 | yes | — |
before_after.welcome_bonus_points | object | no | — |
before_after.welcome_bonus_points.before | integer, at least 0 | yes | — |
before_after.welcome_bonus_points.after | integer, at least 0 | yes | — |
before_after.birthday_bonus_points | object | no | — |
before_after.birthday_bonus_points.before | integer, at least 0 | yes | — |
before_after.birthday_bonus_points.after | integer, at least 0 | yes | — |
before_after.point_value | object | no | — |
before_after.point_value.before | object | yes | — |
before_after.point_value.before.points | integer, at least 1 | yes | — |
before_after.point_value.before.amount | always 1.00 | yes | — |
before_after.point_value.after | object | yes | — |
before_after.point_value.after.points | integer, at least 1 | yes | — |
before_after.point_value.after.amount | always 1.00 | yes | — |
before_after.timezone | object | no | — |
before_after.timezone.before | string | yes | — |
before_after.timezone.after | string | yes | — |
before_after.ways_to_earn | object | no | — |
before_after.ways_to_earn.before | list of object | yes | — |
before_after.ways_to_earn.before[].id | string | yes | — |
before_after.ways_to_earn.before[].label | string | yes | — |
before_after.ways_to_earn.before[].detail | string | yes | — |
before_after.ways_to_earn.after | list of object | yes | — |
before_after.ways_to_earn.after[].id | string | yes | — |
before_after.ways_to_earn.after[].label | string | yes | — |
before_after.ways_to_earn.after[].detail | string | yes | — |
before_after.referral | object | no | — |
before_after.referral.before | any, or null or object | yes | — |
before_after.referral.after | any, or null or object | yes | — |
before_after.spend_any_amount | object | no | — |
before_after.spend_any_amount.before | any, or null or object | yes | — |
before_after.spend_any_amount.after | any, or null or object | yes | — |
before_after.reward | object | no | — |
before_after.reward.before | any, or null or object (ApiReward) | yes | — |
before_after.reward.after | any, or null or object (ApiReward) | yes | — |
currency | string, or null | yes | — |
examples | list of object | yes | What a plain order of each size earns now, before and after the change, under every offer running now. |
examples[].order_amount | string | yes | — |
examples[].points_before | integer | yes | — |
examples[].points_after | integer | yes | — |
point_value | object | yes | — |
point_value.points_per_currency_unit_before | integer, or null | yes | — |
point_value.points_per_currency_unit_after | integer, or null | yes | — |
liability | any, or null or object or object | yes | When the point value changes: what the points shoppers hold are worth before and after. Null when it does not change; unavailable when the account has more than one store. |
version_after | string, matching ^[0-9a-f]{32}$, or null | yes | The version the programme would have. |
confirmation | any, or null or object | yes | For a key tagged for an assistant: send token back as the Matrix-Confirmation header with the same request. Null when nothing would change. |
audit_id | string, or null | yes | — |
Headers on the answer: Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset; and, when it applies, Idempotency-Replayed. What each header means.
Example
export STORE='<key of a store from GET /stores>'
curl -X POST "https://app.matrixrewards.co.uk/api/2026-10/stores/$STORE/programme/rewards" \
-H "Authorization: Bearer $MATRIX_API_KEY" \
-H "Idempotency-Key: reward-add-2026-09-16-0001" \
-H "Content-Type: application/json" \
-d '{"cost_points": 1000, "value_amount": "10.00"}'
The reward, added:
{
"object": "reward",
"reward": {
"id": "reward_6",
"kind": "amount",
"cost_points": 1000,
"value_amount": "10.00",
"percent_off": null,
"min_subtotal": "0.00",
"label": "£10.00 off"
},
"warnings": [],
"store_key": "example-store",
"version": "9b2e4c1d7a8f60519283a4b5c6d7e8f1",
"changed": [
"rewards"
],
"as_paid": {
"earn_rate_per_major_unit": 1,
"earn_rates_per_major_unit": [
1
],
"welcome_bonus_points": 0,
"ways_to_earn": []
},
"audit_id": "0d1e2f30-4a5b-4c6d-8e7f-000000000010"
}
The same request with ?dry_run=true: nothing changes:
{
"object": "reward_preview",
"reward": {
"id": "reward_6",
"kind": "amount",
"cost_points": 1000,
"value_amount": "10.00",
"percent_off": null,
"min_subtotal": "0.00",
"label": "£10.00 off"
},
"warnings": [],
"dry_run": true,
"outcome": "would_apply",
"store_key": "example-store",
"changed": [
"rewards"
],
"before_after": {
"reward": {
"before": null,
"after": {
"id": "reward_6",
"kind": "amount",
"cost_points": 1000,
"value_amount": "10.00",
"percent_off": null,
"min_subtotal": "0.00",
"label": "£10.00 off"
}
}
},
"currency": "GBP",
"examples": [
{
"order_amount": "10.00",
"points_before": 10,
"points_after": 10
},
{
"order_amount": "50.00",
"points_before": 50,
"points_after": 50
},
{
"order_amount": "100.00",
"points_before": 100,
"points_after": 100
}
],
"point_value": {
"points_per_currency_unit_before": 100,
"points_per_currency_unit_after": 100
},
"liability": null,
"version_after": "9b2e4c1d7a8f60519283a4b5c6d7e8f1",
"confirmation": {
"token": "mct_EXAMPLE.EXAMPLE",
"expires_at": "2026-09-16T10:10:00.000Z"
},
"audit_id": "0d1e2f30-4a5b-4c6d-8e7f-000000000011"
}
Other answers
| Status | What it means | Error pages |
|---|---|---|
| 400 | invalid_request: a parameter or the body could not be read; param names it. | invalid_request |
| 401 | unauthenticated: no usable API key was presented. Malformed, unknown, revoked and wrong-secret are one answer. | unauthenticated |
| 403 | forbidden_scope: the key does not hold write_programme (required_scope says so). test_mode_key: a test key can preview a change and never make one. assistant_limit: the key is an assistant's (every Claude connection, and a key made for an assistant), the merchant has not lifted its fixed limits (/me key.limits_lifted; only a person can lift them, on the app's API keys screen), and the change moves the store's money further than one step. It is measured from the programme as it stood before the assistant's changes began, so steps add up; that starting point holds until assistant_window_hours pass with no assistant change, and a change by the owner to what the programme pays starts afresh (a new label or reason does not; nor does a change by an assistant key whose limits are lifted). What a point is worth, the currency and when points expire never change. What an order earns at most doubles: a new offer counts as paying every order, against what every order earned; and the everyday rate, which still prices some refunds (an API refund that names no order, and an order paid before amounts were recorded), is not lowered to fund an offer (then the figures are the everyday rate's own, least_allowed is the least it may be for every order to earn what it did, up to the field's two decimal places — null when no rate within its own step would do — and on an offer's route no param is named). Every other figure that pays shoppers at most doubles, one at zero stays there, and referrals that are off stay off. Spending any amount that is off stays off; while it is on, the fewest points one code may take and the smallest order it can be used on at most halve, and the most points at most double (a limit cannot be removed); switching it off is never limited. An offer may only narrow who and when it pays. A reward at most doubles what it gives for each point, keeps its kind, and its smallest order at most halves; a new reward stays within one step of a reward of its kind. The preview is refused the same way. problem says which limit; before, asked and most_allowed or least_allowed give the figure in the unit of param, and sending that limit back is accepted. | forbidden_scope, test_mode_key, assistant_limit |
| 404 | not_found: no such member on this account, or (cancelling a code) the member holds no code with that ref. | not_found |
| 409 | idempotency_key_in_use: a request with this key is still running (Retry-After). settings_changed: the programme changed after it was read — by if_version, or by the reading this change was built over — so nothing was changed; read it again and resend. | idempotency_key_in_use, settings_changed |
| 413 | payload_too_large: the body is over 4,096 bytes. | payload_too_large |
| 422 | idempotency_key_reused: this Idempotency-Key was used for a different request in the last 24 hours. | idempotency_key_reused |
| 428 | confirmation_required: this key is tagged for an assistant; preview with dry_run=true and send the returned confirmation. confirmation_invalid: the confirmation is not for this request, has expired, or is not one we issued (reason). | confirmation_required, confirmation_invalid |
| 429 | rate_limited: too many requests for this key (Retry-After). programme_write_limit: this account has made 100 programme changes through the API in the last 24 hours (limit, used); nothing was changed. | rate_limited, programme_write_limit |
| 503 | change_not_recorded (or access_not_recorded for a preview or a replay): the audit log could not be written, so nothing was changed or returned. Retry with the same Idempotency-Key. | change_not_recorded, access_not_recorded |
Change a reward's cost, value, minimum order or label
POST /stores/{store_key}/programme/rewards/{reward_id} — the full address is https://app.matrixrewards.co.uk/api/2026-10/stores/{store_key}/programme/rewards/{reward_id}.
Needs: the write_programme permission (in the app: “Change your programme: the earn rate, the joining and birthday bonuses, what a point is worth, when points expire, the rewards and the offers. Every change is previewable and logged”).
Only the fields sent change. Codes already issued for the reward keep the value they were issued with. The same warnings as an add.
Parameters
| Name | In | Required | Type | What it is |
|---|---|---|---|---|
store_key | path | yes | string | — |
reward_id | path | yes | string, matching ^[A-Za-z0-9._-]{1,64}$ | A reward's id, as listRewards gives it. |
Idempotency-Key | header | yes | string, matching ^[A-Za-z0-9._:-]{1,100}$ | Required on both writes, a points change and a cancel. Pick a new one for each new change and send the same one to retry it. For 24 hours from its first use, a key has three answers: the same request again gets the first answer back, byte for byte, with Idempotency-Replayed: true; a different request under the key is 422 idempotency_key_reused; and while the first request is still running, 409 idempotency_key_in_use with Retry-After. A 5xx does not use the key up: retry with the same one. For a points change the key is also the adjustment's id in the ledger, so the same change is never booked twice, whichever of the account's keys sends it, and even after the 24 hours. |
dry_run | query | no | boolean | true previews the change: every check the real call makes runs, the answer has the status the real call would have, and nothing changes — except one dry_run entry in the audit log, because a preview shows the member's balance. When the change would happen, the preview carries a confirmation for exactly this request. It is the only query parameter a write takes: any other, a misspelling such as dryRun included, is refused with 400 invalid_request naming it in param, and nothing changes. |
Matrix-Confirmation | header | no | string, up to 1,024 characters | The confirmation.token a dry run of this exact request returned. Required for keys tagged for an assistant, except on a replay: a request whose first answer is still kept, or a points change that is already made; checked whenever it is sent. It covers one request — the same key, member, body and Idempotency-Key — for 10 minutes. It proves the change was previewed first; it cannot prove a person saw the preview. |
Request body
Sent as application/json.
| Field | Type | Required | What it is |
|---|---|---|---|
kind | one of amount, percentage, free_shipping | no | What the reward gives: an amount off the order (the default), a percentage off its goods, or free shipping. |
cost_points | integer, from 1 to 10,000,000 | no | What the reward costs, in points. |
value_amount | string, matching ^\d{1,7}(?:\.\d{1,2})?$ | no | For an amount reward: how much it takes off an order, in the store's currency, more than zero. Not sent for the other kinds. |
percent_off | integer, from 1 to 100 | no | For a percentage reward: the percentage off, a whole number. |
min_subtotal | string, matching ^\d{1,7}(?:\.\d{1,2})?$ | no | The smallest order it can be used on; "0.00" (the default) for any order. |
label | string, up to 64 characters, at least 1 character | no | What the shopper sees. Left out on an add, it is the value in the store's currency, like "£5.00 off". |
if_version | string, matching ^[0-9a-f]{32}$ | no | The version from a programme read. If the programme has changed since, nothing is changed (409 settings_changed). |
The answer
The change, or with dry_run=true the preview of it. A request already answered is answered again, byte for byte (Idempotency-Replayed).
One shape: RewardResult.
| Field | Type | Always there | What it is |
|---|---|---|---|
object | always reward | yes | — |
reward | object (ApiReward) | yes | — |
reward.id | string | yes | — |
reward.kind | one of amount, percentage, free_shipping | yes | What the reward gives: an amount off the order (the default), a percentage off its goods, or free shipping. |
reward.cost_points | integer | yes | — |
reward.value_amount | string | yes | For an amount reward, the amount off; "0.00" for the other kinds. |
reward.percent_off | integer, or null | yes | For a percentage reward, the percentage off; otherwise null. |
reward.min_subtotal | string | yes | — |
reward.label | string | yes | — |
warnings | list of one of reward_worth_more_than_points, reward_worth_more_than_minimum_order | yes | What may be a mistake, never a refusal: reward_worth_more_than_points (worth more than 10 times what its points are worth at the store's point value) and reward_worth_more_than_minimum_order (worth more than the smallest order it can be used on; a reward with no minimum can be used on any order, so it always is). Only an amount reward is weighed: a percentage or free shipping is worth what the order makes it. |
store_key | string | yes | — |
version | string, matching ^[0-9a-f]{32}$ | yes | The programme's version after the change. |
changed | list of string | yes | The settings that changed, in the engine's names. Empty when the change asked for what was already set. |
as_paid | object, or null | yes | What a shopper is actually paid, worked out from the rules the same way the storefront and the account page work it out. These are the figures to show a shopper. Null when the store has no programme, which cannot price an order. |
as_paid.earn_rate_per_major_unit | number, at least 0 | yes | Points an order pays per whole unit of the store's currency: the sum of the rules that apply to every order. Conditional bonuses (an app-order bonus, a campaign window, a product rule) are left out, because they hold only for some orders. 0 means nothing pays per unit; say nothing about a rate rather than "earn 0". |
as_paid.earn_rates_per_major_unit | list of number, at least 0 | yes | The same rules one by one. To price an amount, round each rate times the amount DOWN on its own and add the results: that is how an order is paid, and one rounding over the sum can promise a point more than the order pays. |
as_paid.welcome_bonus_points | integer, at least 0 | yes | The joining bonus a new shopper is paid, once. 0 when the store pays none, whatever effective.welcomeBonusPoints says. At a store whose welcome and birthday bonuses are paid only in its own app (the programme's effective.bonusesInAppOnly), it is paid only on the store's own app sign-in, never on a web page: do not offer it there as a web sign-up bonus. The platform template's own welcome sentence in ways_to_earn says so. |
as_paid.ways_to_earn | list of object | yes | effective.waysToEarn as a shopper is shown it. The base-rate sentence the app wrote is rebuilt from earn_rate_per_major_unit, and left out when that is 0. On a store built from one of the platform's templates, the template's own sentences about the rate, the app-order bonus and the joining bonus are served as written while the rules still pay what they say, corrected when a figure has moved, and left out when nothing pays them. Entries the merchant wrote are unchanged. |
as_paid.ways_to_earn[].id | string | yes | — |
as_paid.ways_to_earn[].label | string | yes | — |
as_paid.ways_to_earn[].detail | string | yes | — |
audit_id | string, or null | yes | — |
One shape: RewardPreview.
| Field | Type | Always there | What it is |
|---|---|---|---|
object | always reward_preview | yes | — |
reward | object (ApiReward) | yes | — |
reward.id | string | yes | — |
reward.kind | one of amount, percentage, free_shipping | yes | What the reward gives: an amount off the order (the default), a percentage off its goods, or free shipping. |
reward.cost_points | integer | yes | — |
reward.value_amount | string | yes | For an amount reward, the amount off; "0.00" for the other kinds. |
reward.percent_off | integer, or null | yes | For a percentage reward, the percentage off; otherwise null. |
reward.min_subtotal | string | yes | — |
reward.label | string | yes | — |
warnings | list of one of reward_worth_more_than_points, reward_worth_more_than_minimum_order | yes | What may be a mistake, never a refusal: reward_worth_more_than_points (worth more than 10 times what its points are worth at the store's point value) and reward_worth_more_than_minimum_order (worth more than the smallest order it can be used on; a reward with no minimum can be used on any order, so it always is). Only an amount reward is weighed: a percentage or free shipping is worth what the order makes it. |
dry_run | always true | yes | — |
outcome | one of would_apply, no_change | yes | — |
store_key | string | yes | — |
changed | list of string | yes | The settings that would change, in the engine's names. What each moves from and to is before_after. |
before_after | object (PreviewBeforeAfter) | yes | Every figure the change moves, before and after, in this API's own write field names, worked out from the programme as it would price orders before and after (the same way the storefront works them out). Only the ones that differ are here, so {} means none moves. points_per_currency_unit is the everyday rate an order pays (a running campaign or other offer left out); welcome_bonus_points and birthday_bonus_points are 0 when none is paid; point_value is how many points are worth one unit of the currency; ways_to_earn is the list as a shopper reads it. On a reward's call, reward is the reward itself: null before an add, null after a removal. |
before_after.points_per_currency_unit | object | no | — |
before_after.points_per_currency_unit.before | number, at least 0 | yes | — |
before_after.points_per_currency_unit.after | number, at least 0 | yes | — |
before_after.welcome_bonus_points | object | no | — |
before_after.welcome_bonus_points.before | integer, at least 0 | yes | — |
before_after.welcome_bonus_points.after | integer, at least 0 | yes | — |
before_after.birthday_bonus_points | object | no | — |
before_after.birthday_bonus_points.before | integer, at least 0 | yes | — |
before_after.birthday_bonus_points.after | integer, at least 0 | yes | — |
before_after.point_value | object | no | — |
before_after.point_value.before | object | yes | — |
before_after.point_value.before.points | integer, at least 1 | yes | — |
before_after.point_value.before.amount | always 1.00 | yes | — |
before_after.point_value.after | object | yes | — |
before_after.point_value.after.points | integer, at least 1 | yes | — |
before_after.point_value.after.amount | always 1.00 | yes | — |
before_after.timezone | object | no | — |
before_after.timezone.before | string | yes | — |
before_after.timezone.after | string | yes | — |
before_after.ways_to_earn | object | no | — |
before_after.ways_to_earn.before | list of object | yes | — |
before_after.ways_to_earn.before[].id | string | yes | — |
before_after.ways_to_earn.before[].label | string | yes | — |
before_after.ways_to_earn.before[].detail | string | yes | — |
before_after.ways_to_earn.after | list of object | yes | — |
before_after.ways_to_earn.after[].id | string | yes | — |
before_after.ways_to_earn.after[].label | string | yes | — |
before_after.ways_to_earn.after[].detail | string | yes | — |
before_after.referral | object | no | — |
before_after.referral.before | any, or null or object | yes | — |
before_after.referral.after | any, or null or object | yes | — |
before_after.spend_any_amount | object | no | — |
before_after.spend_any_amount.before | any, or null or object | yes | — |
before_after.spend_any_amount.after | any, or null or object | yes | — |
before_after.reward | object | no | — |
before_after.reward.before | any, or null or object (ApiReward) | yes | — |
before_after.reward.after | any, or null or object (ApiReward) | yes | — |
currency | string, or null | yes | — |
examples | list of object | yes | What a plain order of each size earns now, before and after the change, under every offer running now. |
examples[].order_amount | string | yes | — |
examples[].points_before | integer | yes | — |
examples[].points_after | integer | yes | — |
point_value | object | yes | — |
point_value.points_per_currency_unit_before | integer, or null | yes | — |
point_value.points_per_currency_unit_after | integer, or null | yes | — |
liability | any, or null or object or object | yes | When the point value changes: what the points shoppers hold are worth before and after. Null when it does not change; unavailable when the account has more than one store. |
version_after | string, matching ^[0-9a-f]{32}$, or null | yes | The version the programme would have. |
confirmation | any, or null or object | yes | For a key tagged for an assistant: send token back as the Matrix-Confirmation header with the same request. Null when nothing would change. |
audit_id | string, or null | yes | — |
Headers on the answer: Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset; and, when it applies, Idempotency-Replayed. What each header means.
Example
export STORE='<key of a store from GET /stores>'
export REWARD='<id of a reward from GET /stores/{store_key}/programme/rewards>'
curl -X POST "https://app.matrixrewards.co.uk/api/2026-10/stores/$STORE/programme/rewards/$REWARD" \
-H "Authorization: Bearer $MATRIX_API_KEY" \
-H "Idempotency-Key: reward-change-2026-09-16-0001" \
-H "Content-Type: application/json" \
-d '{"cost_points": 800}'
The reward, changed:
{
"object": "reward",
"reward": {
"id": "reward_6",
"kind": "amount",
"cost_points": 800,
"value_amount": "10.00",
"percent_off": null,
"min_subtotal": "0.00",
"label": "£10.00 off"
},
"warnings": [],
"store_key": "example-store",
"version": "9b2e4c1d7a8f60519283a4b5c6d7e8f2",
"changed": [
"rewards"
],
"as_paid": {
"earn_rate_per_major_unit": 1,
"earn_rates_per_major_unit": [
1
],
"welcome_bonus_points": 0,
"ways_to_earn": []
},
"audit_id": "0d1e2f30-4a5b-4c6d-8e7f-000000000012"
}
The same request with ?dry_run=true: nothing changes:
{
"object": "reward_preview",
"reward": {
"id": "reward_6",
"kind": "amount",
"cost_points": 800,
"value_amount": "10.00",
"percent_off": null,
"min_subtotal": "0.00",
"label": "£10.00 off"
},
"warnings": [],
"dry_run": true,
"outcome": "would_apply",
"store_key": "example-store",
"changed": [
"rewards"
],
"before_after": {
"reward": {
"before": {
"id": "reward_6",
"kind": "amount",
"cost_points": 1000,
"value_amount": "10.00",
"percent_off": null,
"min_subtotal": "0.00",
"label": "£10.00 off"
},
"after": {
"id": "reward_6",
"kind": "amount",
"cost_points": 800,
"value_amount": "10.00",
"percent_off": null,
"min_subtotal": "0.00",
"label": "£10.00 off"
}
}
},
"currency": "GBP",
"examples": [
{
"order_amount": "10.00",
"points_before": 10,
"points_after": 10
},
{
"order_amount": "50.00",
"points_before": 50,
"points_after": 50
},
{
"order_amount": "100.00",
"points_before": 100,
"points_after": 100
}
],
"point_value": {
"points_per_currency_unit_before": 100,
"points_per_currency_unit_after": 100
},
"liability": null,
"version_after": "9b2e4c1d7a8f60519283a4b5c6d7e8f2",
"confirmation": {
"token": "mct_EXAMPLE.EXAMPLE",
"expires_at": "2026-09-16T10:10:00.000Z"
},
"audit_id": "0d1e2f30-4a5b-4c6d-8e7f-000000000013"
}
Other answers
| Status | What it means | Error pages |
|---|---|---|
| 400 | invalid_request: a parameter or the body could not be read; param names it. | invalid_request |
| 401 | unauthenticated: no usable API key was presented. Malformed, unknown, revoked and wrong-secret are one answer. | unauthenticated |
| 403 | forbidden_scope: the key does not hold write_programme (required_scope says so). test_mode_key: a test key can preview a change and never make one. assistant_limit: the key is an assistant's (every Claude connection, and a key made for an assistant), the merchant has not lifted its fixed limits (/me key.limits_lifted; only a person can lift them, on the app's API keys screen), and the change moves the store's money further than one step. It is measured from the programme as it stood before the assistant's changes began, so steps add up; that starting point holds until assistant_window_hours pass with no assistant change, and a change by the owner to what the programme pays starts afresh (a new label or reason does not; nor does a change by an assistant key whose limits are lifted). What a point is worth, the currency and when points expire never change. What an order earns at most doubles: a new offer counts as paying every order, against what every order earned; and the everyday rate, which still prices some refunds (an API refund that names no order, and an order paid before amounts were recorded), is not lowered to fund an offer (then the figures are the everyday rate's own, least_allowed is the least it may be for every order to earn what it did, up to the field's two decimal places — null when no rate within its own step would do — and on an offer's route no param is named). Every other figure that pays shoppers at most doubles, one at zero stays there, and referrals that are off stay off. Spending any amount that is off stays off; while it is on, the fewest points one code may take and the smallest order it can be used on at most halve, and the most points at most double (a limit cannot be removed); switching it off is never limited. An offer may only narrow who and when it pays. A reward at most doubles what it gives for each point, keeps its kind, and its smallest order at most halves; a new reward stays within one step of a reward of its kind. The preview is refused the same way. problem says which limit; before, asked and most_allowed or least_allowed give the figure in the unit of param, and sending that limit back is accepted. | forbidden_scope, test_mode_key, assistant_limit |
| 404 | not_found: no such member on this account, or (cancelling a code) the member holds no code with that ref. | not_found |
| 409 | idempotency_key_in_use: a request with this key is still running (Retry-After). settings_changed: the programme changed after it was read — by if_version, or by the reading this change was built over — so nothing was changed; read it again and resend. | idempotency_key_in_use, settings_changed |
| 413 | payload_too_large: the body is over 4,096 bytes. | payload_too_large |
| 422 | idempotency_key_reused: this Idempotency-Key was used for a different request in the last 24 hours. | idempotency_key_reused |
| 428 | confirmation_required: this key is tagged for an assistant; preview with dry_run=true and send the returned confirmation. confirmation_invalid: the confirmation is not for this request, has expired, or is not one we issued (reason). | confirmation_required, confirmation_invalid |
| 429 | rate_limited: too many requests for this key (Retry-After). programme_write_limit: this account has made 100 programme changes through the API in the last 24 hours (limit, used); nothing was changed. | rate_limited, programme_write_limit |
| 503 | change_not_recorded (or access_not_recorded for a preview or a replay): the audit log could not be written, so nothing was changed or returned. Retry with the same Idempotency-Key. | change_not_recorded, access_not_recorded |
Remove a reward
POST /stores/{store_key}/programme/rewards/{reward_id}/remove — the full address is https://app.matrixrewards.co.uk/api/2026-10/stores/{store_key}/programme/rewards/{reward_id}/remove.
Needs: the write_programme permission (in the app: “Change your programme: the earn rate, the joining and birthday bonuses, what a point is worth, when points expire, the rewards and the offers. Every change is previewable and logged”).
Codes already issued for it stay valid. The store's only reward cannot be removed (409 last_reward): a programme with nothing to spend points on is a trap.
Parameters
| Name | In | Required | Type | What it is |
|---|---|---|---|---|
store_key | path | yes | string | — |
reward_id | path | yes | string, matching ^[A-Za-z0-9._-]{1,64}$ | A reward's id, as listRewards gives it. |
Idempotency-Key | header | yes | string, matching ^[A-Za-z0-9._:-]{1,100}$ | Required on both writes, a points change and a cancel. Pick a new one for each new change and send the same one to retry it. For 24 hours from its first use, a key has three answers: the same request again gets the first answer back, byte for byte, with Idempotency-Replayed: true; a different request under the key is 422 idempotency_key_reused; and while the first request is still running, 409 idempotency_key_in_use with Retry-After. A 5xx does not use the key up: retry with the same one. For a points change the key is also the adjustment's id in the ledger, so the same change is never booked twice, whichever of the account's keys sends it, and even after the 24 hours. |
dry_run | query | no | boolean | true previews the change: every check the real call makes runs, the answer has the status the real call would have, and nothing changes — except one dry_run entry in the audit log, because a preview shows the member's balance. When the change would happen, the preview carries a confirmation for exactly this request. It is the only query parameter a write takes: any other, a misspelling such as dryRun included, is refused with 400 invalid_request naming it in param, and nothing changes. |
Matrix-Confirmation | header | no | string, up to 1,024 characters | The confirmation.token a dry run of this exact request returned. Required for keys tagged for an assistant, except on a replay: a request whose first answer is still kept, or a points change that is already made; checked whenever it is sent. It covers one request — the same key, member, body and Idempotency-Key — for 10 minutes. It proves the change was previewed first; it cannot prove a person saw the preview. |
Request body
Sent as application/json.
| Field | Type | Required | What it is |
|---|---|---|---|
if_version | string, matching ^[0-9a-f]{32}$ | no | The version from a programme read. If the programme has changed since, nothing is changed (409 settings_changed). |
The answer
The change, or with dry_run=true the preview of it. A request already answered is answered again, byte for byte (Idempotency-Replayed).
One shape: RewardRemoved.
| Field | Type | Always there | What it is |
|---|---|---|---|
object | always reward_removed | yes | — |
reward_id | string | yes | — |
store_key | string | yes | — |
version | string, matching ^[0-9a-f]{32}$ | yes | The programme's version after the change. |
changed | list of string | yes | The settings that changed, in the engine's names. Empty when the change asked for what was already set. |
as_paid | object, or null | yes | What a shopper is actually paid, worked out from the rules the same way the storefront and the account page work it out. These are the figures to show a shopper. Null when the store has no programme, which cannot price an order. |
as_paid.earn_rate_per_major_unit | number, at least 0 | yes | Points an order pays per whole unit of the store's currency: the sum of the rules that apply to every order. Conditional bonuses (an app-order bonus, a campaign window, a product rule) are left out, because they hold only for some orders. 0 means nothing pays per unit; say nothing about a rate rather than "earn 0". |
as_paid.earn_rates_per_major_unit | list of number, at least 0 | yes | The same rules one by one. To price an amount, round each rate times the amount DOWN on its own and add the results: that is how an order is paid, and one rounding over the sum can promise a point more than the order pays. |
as_paid.welcome_bonus_points | integer, at least 0 | yes | The joining bonus a new shopper is paid, once. 0 when the store pays none, whatever effective.welcomeBonusPoints says. At a store whose welcome and birthday bonuses are paid only in its own app (the programme's effective.bonusesInAppOnly), it is paid only on the store's own app sign-in, never on a web page: do not offer it there as a web sign-up bonus. The platform template's own welcome sentence in ways_to_earn says so. |
as_paid.ways_to_earn | list of object | yes | effective.waysToEarn as a shopper is shown it. The base-rate sentence the app wrote is rebuilt from earn_rate_per_major_unit, and left out when that is 0. On a store built from one of the platform's templates, the template's own sentences about the rate, the app-order bonus and the joining bonus are served as written while the rules still pay what they say, corrected when a figure has moved, and left out when nothing pays them. Entries the merchant wrote are unchanged. |
as_paid.ways_to_earn[].id | string | yes | — |
as_paid.ways_to_earn[].label | string | yes | — |
as_paid.ways_to_earn[].detail | string | yes | — |
audit_id | string, or null | yes | — |
One shape: RewardRemovedPreview.
| Field | Type | Always there | What it is |
|---|---|---|---|
object | always reward_removed_preview | yes | — |
reward_id | string | yes | — |
dry_run | always true | yes | — |
outcome | one of would_apply, no_change | yes | — |
store_key | string | yes | — |
changed | list of string | yes | The settings that would change, in the engine's names. What each moves from and to is before_after. |
before_after | object (PreviewBeforeAfter) | yes | Every figure the change moves, before and after, in this API's own write field names, worked out from the programme as it would price orders before and after (the same way the storefront works them out). Only the ones that differ are here, so {} means none moves. points_per_currency_unit is the everyday rate an order pays (a running campaign or other offer left out); welcome_bonus_points and birthday_bonus_points are 0 when none is paid; point_value is how many points are worth one unit of the currency; ways_to_earn is the list as a shopper reads it. On a reward's call, reward is the reward itself: null before an add, null after a removal. |
before_after.points_per_currency_unit | object | no | — |
before_after.points_per_currency_unit.before | number, at least 0 | yes | — |
before_after.points_per_currency_unit.after | number, at least 0 | yes | — |
before_after.welcome_bonus_points | object | no | — |
before_after.welcome_bonus_points.before | integer, at least 0 | yes | — |
before_after.welcome_bonus_points.after | integer, at least 0 | yes | — |
before_after.birthday_bonus_points | object | no | — |
before_after.birthday_bonus_points.before | integer, at least 0 | yes | — |
before_after.birthday_bonus_points.after | integer, at least 0 | yes | — |
before_after.point_value | object | no | — |
before_after.point_value.before | object | yes | — |
before_after.point_value.before.points | integer, at least 1 | yes | — |
before_after.point_value.before.amount | always 1.00 | yes | — |
before_after.point_value.after | object | yes | — |
before_after.point_value.after.points | integer, at least 1 | yes | — |
before_after.point_value.after.amount | always 1.00 | yes | — |
before_after.timezone | object | no | — |
before_after.timezone.before | string | yes | — |
before_after.timezone.after | string | yes | — |
before_after.ways_to_earn | object | no | — |
before_after.ways_to_earn.before | list of object | yes | — |
before_after.ways_to_earn.before[].id | string | yes | — |
before_after.ways_to_earn.before[].label | string | yes | — |
before_after.ways_to_earn.before[].detail | string | yes | — |
before_after.ways_to_earn.after | list of object | yes | — |
before_after.ways_to_earn.after[].id | string | yes | — |
before_after.ways_to_earn.after[].label | string | yes | — |
before_after.ways_to_earn.after[].detail | string | yes | — |
before_after.referral | object | no | — |
before_after.referral.before | any, or null or object | yes | — |
before_after.referral.after | any, or null or object | yes | — |
before_after.spend_any_amount | object | no | — |
before_after.spend_any_amount.before | any, or null or object | yes | — |
before_after.spend_any_amount.after | any, or null or object | yes | — |
before_after.reward | object | no | — |
before_after.reward.before | any, or null or object (ApiReward) | yes | — |
before_after.reward.after | any, or null or object (ApiReward) | yes | — |
currency | string, or null | yes | — |
examples | list of object | yes | What a plain order of each size earns now, before and after the change, under every offer running now. |
examples[].order_amount | string | yes | — |
examples[].points_before | integer | yes | — |
examples[].points_after | integer | yes | — |
point_value | object | yes | — |
point_value.points_per_currency_unit_before | integer, or null | yes | — |
point_value.points_per_currency_unit_after | integer, or null | yes | — |
liability | any, or null or object or object | yes | When the point value changes: what the points shoppers hold are worth before and after. Null when it does not change; unavailable when the account has more than one store. |
version_after | string, matching ^[0-9a-f]{32}$, or null | yes | The version the programme would have. |
confirmation | any, or null or object | yes | For a key tagged for an assistant: send token back as the Matrix-Confirmation header with the same request. Null when nothing would change. |
audit_id | string, or null | yes | — |
Headers on the answer: Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset; and, when it applies, Idempotency-Replayed. What each header means.
Example
export STORE='<key of a store from GET /stores>'
export REWARD='<id of a reward from GET /stores/{store_key}/programme/rewards>'
curl -X POST "https://app.matrixrewards.co.uk/api/2026-10/stores/$STORE/programme/rewards/$REWARD/remove" \
-H "Authorization: Bearer $MATRIX_API_KEY" \
-H "Idempotency-Key: reward-remove-2026-09-16-0001" \
-H "Content-Type: application/json" \
-d '{}'
The reward, removed:
{
"object": "reward_removed",
"reward_id": "reward_6",
"store_key": "example-store",
"version": "9b2e4c1d7a8f60519283a4b5c6d7e8f3",
"changed": [
"rewards"
],
"as_paid": {
"earn_rate_per_major_unit": 1,
"earn_rates_per_major_unit": [
1
],
"welcome_bonus_points": 0,
"ways_to_earn": []
},
"audit_id": "0d1e2f30-4a5b-4c6d-8e7f-000000000014"
}
The same request with ?dry_run=true: nothing changes:
{
"object": "reward_removed_preview",
"reward_id": "reward_6",
"dry_run": true,
"outcome": "would_apply",
"store_key": "example-store",
"changed": [
"rewards"
],
"before_after": {
"reward": {
"before": {
"id": "reward_6",
"kind": "amount",
"cost_points": 800,
"value_amount": "10.00",
"percent_off": null,
"min_subtotal": "0.00",
"label": "£10.00 off"
},
"after": null
}
},
"currency": "GBP",
"examples": [
{
"order_amount": "10.00",
"points_before": 10,
"points_after": 10
},
{
"order_amount": "50.00",
"points_before": 50,
"points_after": 50
},
{
"order_amount": "100.00",
"points_before": 100,
"points_after": 100
}
],
"point_value": {
"points_per_currency_unit_before": 100,
"points_per_currency_unit_after": 100
},
"liability": null,
"version_after": "9b2e4c1d7a8f60519283a4b5c6d7e8f3",
"confirmation": {
"token": "mct_EXAMPLE.EXAMPLE",
"expires_at": "2026-09-16T10:10:00.000Z"
},
"audit_id": "0d1e2f30-4a5b-4c6d-8e7f-000000000015"
}
Other answers
| Status | What it means | Error pages |
|---|---|---|
| 400 | invalid_request: a parameter or the body could not be read; param names it. | invalid_request |
| 401 | unauthenticated: no usable API key was presented. Malformed, unknown, revoked and wrong-secret are one answer. | unauthenticated |
| 403 | forbidden_scope: the key does not hold write_programme (required_scope says so). test_mode_key: a test key can preview a change and never make one. assistant_limit: the key is an assistant's (every Claude connection, and a key made for an assistant), the merchant has not lifted its fixed limits (/me key.limits_lifted; only a person can lift them, on the app's API keys screen), and the change moves the store's money further than one step. It is measured from the programme as it stood before the assistant's changes began, so steps add up; that starting point holds until assistant_window_hours pass with no assistant change, and a change by the owner to what the programme pays starts afresh (a new label or reason does not; nor does a change by an assistant key whose limits are lifted). What a point is worth, the currency and when points expire never change. What an order earns at most doubles: a new offer counts as paying every order, against what every order earned; and the everyday rate, which still prices some refunds (an API refund that names no order, and an order paid before amounts were recorded), is not lowered to fund an offer (then the figures are the everyday rate's own, least_allowed is the least it may be for every order to earn what it did, up to the field's two decimal places — null when no rate within its own step would do — and on an offer's route no param is named). Every other figure that pays shoppers at most doubles, one at zero stays there, and referrals that are off stay off. Spending any amount that is off stays off; while it is on, the fewest points one code may take and the smallest order it can be used on at most halve, and the most points at most double (a limit cannot be removed); switching it off is never limited. An offer may only narrow who and when it pays. A reward at most doubles what it gives for each point, keeps its kind, and its smallest order at most halves; a new reward stays within one step of a reward of its kind. The preview is refused the same way. problem says which limit; before, asked and most_allowed or least_allowed give the figure in the unit of param, and sending that limit back is accepted. | forbidden_scope, test_mode_key, assistant_limit |
| 404 | not_found: no such member on this account, or (cancelling a code) the member holds no code with that ref. | not_found |
| 409 | idempotency_key_in_use: a request with this key is still running (Retry-After). settings_changed: the programme changed after it was read, so nothing was changed. last_reward: this is the store's only reward, and a programme with nothing to spend points on is not allowed; add another first. | idempotency_key_in_use, settings_changed, last_reward |
| 413 | payload_too_large: the body is over 4,096 bytes. | payload_too_large |
| 422 | idempotency_key_reused: this Idempotency-Key was used for a different request in the last 24 hours. | idempotency_key_reused |
| 428 | confirmation_required: this key is tagged for an assistant; preview with dry_run=true and send the returned confirmation. confirmation_invalid: the confirmation is not for this request, has expired, or is not one we issued (reason). | confirmation_required, confirmation_invalid |
| 429 | rate_limited: too many requests for this key (Retry-After). programme_write_limit: this account has made 100 programme changes through the API in the last 24 hours (limit, used); nothing was changed. | rate_limited, programme_write_limit |
| 503 | change_not_recorded (or access_not_recorded for a preview or a replay): the audit log could not be written, so nothing was changed or returned. Retry with the same Idempotency-Key. | change_not_recorded, access_not_recorded |
A store's offers
GET /stores/{store_key}/programme/offers — the full address is https://app.matrixrewards.co.uk/api/2026-10/stores/{store_key}/programme/offers.
Needs: the read_programme permission (in the app: “Your programme settings — rates, rewards, rules”). A key with write_programme can call it too.
Campaigns, product offers and channel offers in the words the offer calls take, and every other rule as other, read-only. The everyday rate and the two bonuses are not offers: they are in getProgramme.
Parameters
| Name | In | Required | Type | What it is |
|---|---|---|---|---|
store_key | path | yes | string | — |
The answer
The store's offers.
| Field | Type | Always there | What it is |
|---|---|---|---|
data | list of object (ApiOffer) | yes | — |
data[].id | string | yes | — |
data[].kind | one of campaign, product, channel, other | yes | — |
data[].reason | string | yes | What the shopper is told the points are for. |
data[].read_only | boolean | yes | True for other, and for a campaign that has ended: remove it, but not change it. |
data[].ended | boolean | no | — |
data[].points_per_currency_unit | number | no | — |
data[].starts_at | string | no | — |
data[].ends_at | string | no | — |
data[].starts_on | string | no | The first day, in the store's time zone. |
data[].ends_on | string | no | The last day, in the store's time zone. |
data[].points_per_item | integer | no | — |
data[].product_ids | list of string | no | — |
data[].channel | object | no | Orders from any of these count: a sales channel's source name, an app's id, or an order tag; at most 50 of each, and at least one list not empty. |
data[].channel.source_names | list of string, up to 100 characters, at least 1 character | no | — |
data[].channel.app_ids | list of integer, at least 0 | no | — |
data[].channel.order_tags | list of string, up to 100 characters, at least 1 character | no | — |
has_more | boolean | yes | — |
next_cursor | string, or null | yes | — |
version | string, matching ^[0-9a-f]{32}$ | yes | Which stored programme this is: it changes whenever the store's programme settings change (not its look or expiry), so two reads with the same version saw the same programme. Send it as if_version with a programme change (POST /stores/{store_key}/programme) and the change is refused, 409 settings_changed, if the programme has changed since you read it. The embedded admin's save checks the same version. |
Headers on the answer: Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset. What each header means.
Example
export STORE='<key of a store from GET /stores>'
curl "https://app.matrixrewards.co.uk/api/2026-10/stores/$STORE/programme/offers" \
-H "Authorization: Bearer $MATRIX_API_KEY"
The answer:
{
"data": [
{
"id": "campaign_mfk2q",
"kind": "campaign",
"reason": "Double points week",
"read_only": false,
"ended": false,
"points_per_currency_unit": 1,
"starts_at": "2026-10-05T23:00:00.000Z",
"ends_at": "2026-10-12T23:00:00.000Z",
"starts_on": "2026-10-06",
"ends_on": "2026-10-12"
}
],
"has_more": false,
"next_cursor": null,
"version": "3f1c9a2b7d4e5f60718293a4b5c6d7e8"
}
Other answers
| Status | What it means | Error pages |
|---|---|---|
| 401 | unauthenticated: no usable API key was presented. Malformed, unknown, revoked and wrong-secret are one answer. | unauthenticated |
| 403 | forbidden_scope: the key does not carry the scope this call needs; required_scope says which. | forbidden_scope |
| 404 | not_found: no such resource on this account — indistinguishable from one that does not exist at all. | not_found |
| 429 | rate_limited: too many requests for this key; bucket says which allowance. Retry-After says how long to wait. | rate_limited |
Add an offer: a campaign, a product offer or a channel offer
POST /stores/{store_key}/programme/offers — the full address is https://app.matrixrewards.co.uk/api/2026-10/stores/{store_key}/programme/offers.
Needs: the write_programme permission (in the app: “Change your programme: the earn rate, the joining and birthday bonuses, what a point is worth, when points expire, the rewards and the offers. Every change is previewable and logged”).
The platform makes the offer's id. Every other rule is carried exactly as it is. A campaign cannot start before today or end on a day already past, in the store's time zone; one starting today starts when it is made. The preview prices three orders under the offers running now, so a campaign starting later changes nothing in it yet.
Parameters
| Name | In | Required | Type | What it is |
|---|---|---|---|---|
store_key | path | yes | string | — |
Idempotency-Key | header | yes | string, matching ^[A-Za-z0-9._:-]{1,100}$ | Required on both writes, a points change and a cancel. Pick a new one for each new change and send the same one to retry it. For 24 hours from its first use, a key has three answers: the same request again gets the first answer back, byte for byte, with Idempotency-Replayed: true; a different request under the key is 422 idempotency_key_reused; and while the first request is still running, 409 idempotency_key_in_use with Retry-After. A 5xx does not use the key up: retry with the same one. For a points change the key is also the adjustment's id in the ledger, so the same change is never booked twice, whichever of the account's keys sends it, and even after the 24 hours. |
dry_run | query | no | boolean | true previews the change: every check the real call makes runs, the answer has the status the real call would have, and nothing changes — except one dry_run entry in the audit log, because a preview shows the member's balance. When the change would happen, the preview carries a confirmation for exactly this request. It is the only query parameter a write takes: any other, a misspelling such as dryRun included, is refused with 400 invalid_request naming it in param, and nothing changes. |
Matrix-Confirmation | header | no | string, up to 1,024 characters | The confirmation.token a dry run of this exact request returned. Required for keys tagged for an assistant, except on a replay: a request whose first answer is still kept, or a points change that is already made; checked whenever it is sent. It covers one request — the same key, member, body and Idempotency-Key — for 10 minutes. It proves the change was previewed first; it cannot prove a person saw the preview. |
Request body
Sent as application/json.
| Field | Type | Required | What it is |
|---|---|---|---|
kind | one of campaign, product, channel | no | — |
reason | string, up to 200 characters, at least 1 character | no | — |
points_per_currency_unit | number, at most 1,000 | no | Extra points for every 1 of the store's currency: more than 0, at most 2 decimal places. |
starts_on | string, matching ^\d{4}-\d{2}-\d{2}$ | no | The first day, in the store's time zone: today or later. |
ends_on | string, matching ^\d{4}-\d{2}-\d{2}$ | no | The last day, in the store's time zone: not before starts_on, and not already past. |
points_per_item | integer, from 1 to 1,000,000 | no | — |
product_ids | list of string or integer, at least 1 | no | Shopify product ids, at most 500: numbers, or gid://shopify/Product/… |
channel | object | no | Orders from any of these count: a sales channel's source name, an app's id, or an order tag; at most 50 of each, and at least one list not empty. |
channel.source_names | list of string, up to 100 characters, at least 1 character | no | — |
channel.app_ids | list of integer, at least 0 | no | — |
channel.order_tags | list of string, up to 100 characters, at least 1 character | no | — |
if_version | string, matching ^[0-9a-f]{32}$ | no | The version from a programme read. If the programme has changed since, nothing is changed (409 settings_changed). |
The answer
The change, or with dry_run=true the preview of it. A request already answered is answered again, byte for byte (Idempotency-Replayed).
One shape: OfferResult.
| Field | Type | Always there | What it is |
|---|---|---|---|
object | always offer | yes | — |
offer | object (ApiOffer) | yes | An offer in the store's own days. campaign: extra points per unit between two days. product: points per item of chosen products. channel: extra points per unit on orders from chosen sources. other: set up another way; read-only here. |
offer.id | string | yes | — |
offer.kind | one of campaign, product, channel, other | yes | — |
offer.reason | string | yes | What the shopper is told the points are for. |
offer.read_only | boolean | yes | True for other, and for a campaign that has ended: remove it, but not change it. |
offer.ended | boolean | no | — |
offer.points_per_currency_unit | number | no | — |
offer.starts_at | string | no | — |
offer.ends_at | string | no | — |
offer.starts_on | string | no | The first day, in the store's time zone. |
offer.ends_on | string | no | The last day, in the store's time zone. |
offer.points_per_item | integer | no | — |
offer.product_ids | list of string | no | — |
offer.channel | object | no | Orders from any of these count: a sales channel's source name, an app's id, or an order tag; at most 50 of each, and at least one list not empty. |
offer.channel.source_names | list of string, up to 100 characters, at least 1 character | no | — |
offer.channel.app_ids | list of integer, at least 0 | no | — |
offer.channel.order_tags | list of string, up to 100 characters, at least 1 character | no | — |
store_key | string | yes | — |
version | string, matching ^[0-9a-f]{32}$ | yes | The programme's version after the change. |
changed | list of string | yes | The settings that changed, in the engine's names. Empty when the change asked for what was already set. |
as_paid | object, or null | yes | What a shopper is actually paid, worked out from the rules the same way the storefront and the account page work it out. These are the figures to show a shopper. Null when the store has no programme, which cannot price an order. |
as_paid.earn_rate_per_major_unit | number, at least 0 | yes | Points an order pays per whole unit of the store's currency: the sum of the rules that apply to every order. Conditional bonuses (an app-order bonus, a campaign window, a product rule) are left out, because they hold only for some orders. 0 means nothing pays per unit; say nothing about a rate rather than "earn 0". |
as_paid.earn_rates_per_major_unit | list of number, at least 0 | yes | The same rules one by one. To price an amount, round each rate times the amount DOWN on its own and add the results: that is how an order is paid, and one rounding over the sum can promise a point more than the order pays. |
as_paid.welcome_bonus_points | integer, at least 0 | yes | The joining bonus a new shopper is paid, once. 0 when the store pays none, whatever effective.welcomeBonusPoints says. At a store whose welcome and birthday bonuses are paid only in its own app (the programme's effective.bonusesInAppOnly), it is paid only on the store's own app sign-in, never on a web page: do not offer it there as a web sign-up bonus. The platform template's own welcome sentence in ways_to_earn says so. |
as_paid.ways_to_earn | list of object | yes | effective.waysToEarn as a shopper is shown it. The base-rate sentence the app wrote is rebuilt from earn_rate_per_major_unit, and left out when that is 0. On a store built from one of the platform's templates, the template's own sentences about the rate, the app-order bonus and the joining bonus are served as written while the rules still pay what they say, corrected when a figure has moved, and left out when nothing pays them. Entries the merchant wrote are unchanged. |
as_paid.ways_to_earn[].id | string | yes | — |
as_paid.ways_to_earn[].label | string | yes | — |
as_paid.ways_to_earn[].detail | string | yes | — |
audit_id | string, or null | yes | — |
One shape: OfferPreview.
| Field | Type | Always there | What it is |
|---|---|---|---|
object | always offer_preview | yes | — |
offer | object (ApiOffer) | yes | An offer in the store's own days. campaign: extra points per unit between two days. product: points per item of chosen products. channel: extra points per unit on orders from chosen sources. other: set up another way; read-only here. |
offer.id | string | yes | — |
offer.kind | one of campaign, product, channel, other | yes | — |
offer.reason | string | yes | What the shopper is told the points are for. |
offer.read_only | boolean | yes | True for other, and for a campaign that has ended: remove it, but not change it. |
offer.ended | boolean | no | — |
offer.points_per_currency_unit | number | no | — |
offer.starts_at | string | no | — |
offer.ends_at | string | no | — |
offer.starts_on | string | no | The first day, in the store's time zone. |
offer.ends_on | string | no | The last day, in the store's time zone. |
offer.points_per_item | integer | no | — |
offer.product_ids | list of string | no | — |
offer.channel | object | no | Orders from any of these count: a sales channel's source name, an app's id, or an order tag; at most 50 of each, and at least one list not empty. |
offer.channel.source_names | list of string, up to 100 characters, at least 1 character | no | — |
offer.channel.app_ids | list of integer, at least 0 | no | — |
offer.channel.order_tags | list of string, up to 100 characters, at least 1 character | no | — |
dry_run | always true | yes | — |
outcome | one of would_apply, no_change | yes | — |
store_key | string | yes | — |
changed | list of string | yes | The settings that would change, in the engine's names. What each moves from and to is before_after. |
before_after | object (PreviewBeforeAfter) | yes | Every figure the change moves, before and after, in this API's own write field names, worked out from the programme as it would price orders before and after (the same way the storefront works them out). Only the ones that differ are here, so {} means none moves. points_per_currency_unit is the everyday rate an order pays (a running campaign or other offer left out); welcome_bonus_points and birthday_bonus_points are 0 when none is paid; point_value is how many points are worth one unit of the currency; ways_to_earn is the list as a shopper reads it. On a reward's call, reward is the reward itself: null before an add, null after a removal. |
before_after.points_per_currency_unit | object | no | — |
before_after.points_per_currency_unit.before | number, at least 0 | yes | — |
before_after.points_per_currency_unit.after | number, at least 0 | yes | — |
before_after.welcome_bonus_points | object | no | — |
before_after.welcome_bonus_points.before | integer, at least 0 | yes | — |
before_after.welcome_bonus_points.after | integer, at least 0 | yes | — |
before_after.birthday_bonus_points | object | no | — |
before_after.birthday_bonus_points.before | integer, at least 0 | yes | — |
before_after.birthday_bonus_points.after | integer, at least 0 | yes | — |
before_after.point_value | object | no | — |
before_after.point_value.before | object | yes | — |
before_after.point_value.before.points | integer, at least 1 | yes | — |
before_after.point_value.before.amount | always 1.00 | yes | — |
before_after.point_value.after | object | yes | — |
before_after.point_value.after.points | integer, at least 1 | yes | — |
before_after.point_value.after.amount | always 1.00 | yes | — |
before_after.timezone | object | no | — |
before_after.timezone.before | string | yes | — |
before_after.timezone.after | string | yes | — |
before_after.ways_to_earn | object | no | — |
before_after.ways_to_earn.before | list of object | yes | — |
before_after.ways_to_earn.before[].id | string | yes | — |
before_after.ways_to_earn.before[].label | string | yes | — |
before_after.ways_to_earn.before[].detail | string | yes | — |
before_after.ways_to_earn.after | list of object | yes | — |
before_after.ways_to_earn.after[].id | string | yes | — |
before_after.ways_to_earn.after[].label | string | yes | — |
before_after.ways_to_earn.after[].detail | string | yes | — |
before_after.referral | object | no | — |
before_after.referral.before | any, or null or object | yes | — |
before_after.referral.after | any, or null or object | yes | — |
before_after.spend_any_amount | object | no | — |
before_after.spend_any_amount.before | any, or null or object | yes | — |
before_after.spend_any_amount.after | any, or null or object | yes | — |
before_after.reward | object | no | — |
before_after.reward.before | any, or null or object (ApiReward) | yes | — |
before_after.reward.after | any, or null or object (ApiReward) | yes | — |
currency | string, or null | yes | — |
examples | list of object | yes | What a plain order of each size earns now, before and after the change, under every offer running now. |
examples[].order_amount | string | yes | — |
examples[].points_before | integer | yes | — |
examples[].points_after | integer | yes | — |
point_value | object | yes | — |
point_value.points_per_currency_unit_before | integer, or null | yes | — |
point_value.points_per_currency_unit_after | integer, or null | yes | — |
liability | any, or null or object or object | yes | When the point value changes: what the points shoppers hold are worth before and after. Null when it does not change; unavailable when the account has more than one store. |
version_after | string, matching ^[0-9a-f]{32}$, or null | yes | The version the programme would have. |
confirmation | any, or null or object | yes | For a key tagged for an assistant: send token back as the Matrix-Confirmation header with the same request. Null when nothing would change. |
audit_id | string, or null | yes | — |
Headers on the answer: Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset; and, when it applies, Idempotency-Replayed. What each header means.
Example
export STORE='<key of a store from GET /stores>'
curl -X POST "https://app.matrixrewards.co.uk/api/2026-10/stores/$STORE/programme/offers" \
-H "Authorization: Bearer $MATRIX_API_KEY" \
-H "Idempotency-Key: offer-add-2026-09-16-0001" \
-H "Content-Type: application/json" \
-d '{"kind": "campaign", "reason": "Double points week", "points_per_currency_unit": 1, "starts_on": "2026-10-06", "ends_on": "2026-10-12"}'
The offer, added:
{
"object": "offer",
"offer": {
"id": "campaign_mfk2q",
"kind": "campaign",
"reason": "Double points week",
"read_only": false,
"ended": false,
"points_per_currency_unit": 1,
"starts_at": "2026-10-05T23:00:00.000Z",
"ends_at": "2026-10-12T23:00:00.000Z",
"starts_on": "2026-10-06",
"ends_on": "2026-10-12"
},
"store_key": "example-store",
"version": "9b2e4c1d7a8f60519283a4b5c6d7e8f4",
"changed": [
"rules"
],
"as_paid": {
"earn_rate_per_major_unit": 1,
"earn_rates_per_major_unit": [
1
],
"welcome_bonus_points": 0,
"ways_to_earn": []
},
"audit_id": "0d1e2f30-4a5b-4c6d-8e7f-000000000016"
}
The same request with ?dry_run=true: nothing changes:
{
"object": "offer_preview",
"offer": {
"id": "campaign_mfk2q",
"kind": "campaign",
"reason": "Double points week",
"read_only": false,
"ended": false,
"points_per_currency_unit": 1,
"starts_at": "2026-10-05T23:00:00.000Z",
"ends_at": "2026-10-12T23:00:00.000Z",
"starts_on": "2026-10-06",
"ends_on": "2026-10-12"
},
"dry_run": true,
"outcome": "would_apply",
"store_key": "example-store",
"changed": [
"rules"
],
"before_after": {},
"currency": "GBP",
"examples": [
{
"order_amount": "10.00",
"points_before": 10,
"points_after": 10
},
{
"order_amount": "50.00",
"points_before": 50,
"points_after": 50
},
{
"order_amount": "100.00",
"points_before": 100,
"points_after": 100
}
],
"point_value": {
"points_per_currency_unit_before": 100,
"points_per_currency_unit_after": 100
},
"liability": null,
"version_after": "9b2e4c1d7a8f60519283a4b5c6d7e8f4",
"confirmation": {
"token": "mct_EXAMPLE.EXAMPLE",
"expires_at": "2026-09-16T10:10:00.000Z"
},
"audit_id": "0d1e2f30-4a5b-4c6d-8e7f-000000000017"
}
Other answers
| Status | What it means | Error pages |
|---|---|---|
| 400 | invalid_request: a parameter or the body could not be read; param names it. | invalid_request |
| 401 | unauthenticated: no usable API key was presented. Malformed, unknown, revoked and wrong-secret are one answer. | unauthenticated |
| 403 | forbidden_scope: the key does not hold write_programme (required_scope says so). test_mode_key: a test key can preview a change and never make one. assistant_limit: the key is an assistant's (every Claude connection, and a key made for an assistant), the merchant has not lifted its fixed limits (/me key.limits_lifted; only a person can lift them, on the app's API keys screen), and the change moves the store's money further than one step. It is measured from the programme as it stood before the assistant's changes began, so steps add up; that starting point holds until assistant_window_hours pass with no assistant change, and a change by the owner to what the programme pays starts afresh (a new label or reason does not; nor does a change by an assistant key whose limits are lifted). What a point is worth, the currency and when points expire never change. What an order earns at most doubles: a new offer counts as paying every order, against what every order earned; and the everyday rate, which still prices some refunds (an API refund that names no order, and an order paid before amounts were recorded), is not lowered to fund an offer (then the figures are the everyday rate's own, least_allowed is the least it may be for every order to earn what it did, up to the field's two decimal places — null when no rate within its own step would do — and on an offer's route no param is named). Every other figure that pays shoppers at most doubles, one at zero stays there, and referrals that are off stay off. Spending any amount that is off stays off; while it is on, the fewest points one code may take and the smallest order it can be used on at most halve, and the most points at most double (a limit cannot be removed); switching it off is never limited. An offer may only narrow who and when it pays. A reward at most doubles what it gives for each point, keeps its kind, and its smallest order at most halves; a new reward stays within one step of a reward of its kind. The preview is refused the same way. problem says which limit; before, asked and most_allowed or least_allowed give the figure in the unit of param, and sending that limit back is accepted. | forbidden_scope, test_mode_key, assistant_limit |
| 404 | not_found: no such member on this account, or (cancelling a code) the member holds no code with that ref. | not_found |
| 409 | idempotency_key_in_use: a request with this key is still running (Retry-After). settings_changed: the programme changed after it was read, so nothing was changed. offer_read_only: the rule was set up in a way the API does not change; change it in the app. offer_ended: the campaign has ended, so it can only be removed. | idempotency_key_in_use, settings_changed, offer_read_only, offer_ended |
| 413 | payload_too_large: the body is over 4,096 bytes. | payload_too_large |
| 422 | idempotency_key_reused: this Idempotency-Key was used for a different request in the last 24 hours. | idempotency_key_reused |
| 428 | confirmation_required: this key is tagged for an assistant; preview with dry_run=true and send the returned confirmation. confirmation_invalid: the confirmation is not for this request, has expired, or is not one we issued (reason). | confirmation_required, confirmation_invalid |
| 429 | rate_limited: too many requests for this key (Retry-After). programme_write_limit: this account has made 100 programme changes through the API in the last 24 hours (limit, used); nothing was changed. | rate_limited, programme_write_limit |
| 503 | change_not_recorded (or access_not_recorded for a preview or a replay): the audit log could not be written, so nothing was changed or returned. Retry with the same Idempotency-Key. | change_not_recorded, access_not_recorded |
Change an offer
POST /stores/{store_key}/programme/offers/{offer_id} — the full address is https://app.matrixrewards.co.uk/api/2026-10/stores/{store_key}/programme/offers/{offer_id}.
Needs: the write_programme permission (in the app: “Change your programme: the earn rate, the joining and birthday bonuses, what a point is worth, when points expire, the rewards and the offers. Every change is previewable and logged”).
Send only what changes; kind cannot change. A campaign's start or end is rebuilt only from a day sent, so a window set up to start at a time of day keeps that time until its day is changed. An ended campaign cannot be changed (409 offer_ended), and a rule set up another way is read-only (409 offer_read_only).
Parameters
| Name | In | Required | Type | What it is |
|---|---|---|---|---|
store_key | path | yes | string | — |
offer_id | path | yes | string, matching ^[a-z0-9_]{1,64}$ | An offer's id, as listOffers gives it. |
Idempotency-Key | header | yes | string, matching ^[A-Za-z0-9._:-]{1,100}$ | Required on both writes, a points change and a cancel. Pick a new one for each new change and send the same one to retry it. For 24 hours from its first use, a key has three answers: the same request again gets the first answer back, byte for byte, with Idempotency-Replayed: true; a different request under the key is 422 idempotency_key_reused; and while the first request is still running, 409 idempotency_key_in_use with Retry-After. A 5xx does not use the key up: retry with the same one. For a points change the key is also the adjustment's id in the ledger, so the same change is never booked twice, whichever of the account's keys sends it, and even after the 24 hours. |
dry_run | query | no | boolean | true previews the change: every check the real call makes runs, the answer has the status the real call would have, and nothing changes — except one dry_run entry in the audit log, because a preview shows the member's balance. When the change would happen, the preview carries a confirmation for exactly this request. It is the only query parameter a write takes: any other, a misspelling such as dryRun included, is refused with 400 invalid_request naming it in param, and nothing changes. |
Matrix-Confirmation | header | no | string, up to 1,024 characters | The confirmation.token a dry run of this exact request returned. Required for keys tagged for an assistant, except on a replay: a request whose first answer is still kept, or a points change that is already made; checked whenever it is sent. It covers one request — the same key, member, body and Idempotency-Key — for 10 minutes. It proves the change was previewed first; it cannot prove a person saw the preview. |
Request body
Sent as application/json.
| Field | Type | Required | What it is |
|---|---|---|---|
kind | one of campaign, product, channel | no | — |
reason | string, up to 200 characters, at least 1 character | no | — |
points_per_currency_unit | number, at most 1,000 | no | Extra points for every 1 of the store's currency: more than 0, at most 2 decimal places. |
starts_on | string, matching ^\d{4}-\d{2}-\d{2}$ | no | The first day, in the store's time zone: today or later. |
ends_on | string, matching ^\d{4}-\d{2}-\d{2}$ | no | The last day, in the store's time zone: not before starts_on, and not already past. |
points_per_item | integer, from 1 to 1,000,000 | no | — |
product_ids | list of string or integer, at least 1 | no | Shopify product ids, at most 500: numbers, or gid://shopify/Product/… |
channel | object | no | Orders from any of these count: a sales channel's source name, an app's id, or an order tag; at most 50 of each, and at least one list not empty. |
channel.source_names | list of string, up to 100 characters, at least 1 character | no | — |
channel.app_ids | list of integer, at least 0 | no | — |
channel.order_tags | list of string, up to 100 characters, at least 1 character | no | — |
if_version | string, matching ^[0-9a-f]{32}$ | no | The version from a programme read. If the programme has changed since, nothing is changed (409 settings_changed). |
The answer
The change, or with dry_run=true the preview of it. A request already answered is answered again, byte for byte (Idempotency-Replayed).
One shape: OfferResult.
| Field | Type | Always there | What it is |
|---|---|---|---|
object | always offer | yes | — |
offer | object (ApiOffer) | yes | An offer in the store's own days. campaign: extra points per unit between two days. product: points per item of chosen products. channel: extra points per unit on orders from chosen sources. other: set up another way; read-only here. |
offer.id | string | yes | — |
offer.kind | one of campaign, product, channel, other | yes | — |
offer.reason | string | yes | What the shopper is told the points are for. |
offer.read_only | boolean | yes | True for other, and for a campaign that has ended: remove it, but not change it. |
offer.ended | boolean | no | — |
offer.points_per_currency_unit | number | no | — |
offer.starts_at | string | no | — |
offer.ends_at | string | no | — |
offer.starts_on | string | no | The first day, in the store's time zone. |
offer.ends_on | string | no | The last day, in the store's time zone. |
offer.points_per_item | integer | no | — |
offer.product_ids | list of string | no | — |
offer.channel | object | no | Orders from any of these count: a sales channel's source name, an app's id, or an order tag; at most 50 of each, and at least one list not empty. |
offer.channel.source_names | list of string, up to 100 characters, at least 1 character | no | — |
offer.channel.app_ids | list of integer, at least 0 | no | — |
offer.channel.order_tags | list of string, up to 100 characters, at least 1 character | no | — |
store_key | string | yes | — |
version | string, matching ^[0-9a-f]{32}$ | yes | The programme's version after the change. |
changed | list of string | yes | The settings that changed, in the engine's names. Empty when the change asked for what was already set. |
as_paid | object, or null | yes | What a shopper is actually paid, worked out from the rules the same way the storefront and the account page work it out. These are the figures to show a shopper. Null when the store has no programme, which cannot price an order. |
as_paid.earn_rate_per_major_unit | number, at least 0 | yes | Points an order pays per whole unit of the store's currency: the sum of the rules that apply to every order. Conditional bonuses (an app-order bonus, a campaign window, a product rule) are left out, because they hold only for some orders. 0 means nothing pays per unit; say nothing about a rate rather than "earn 0". |
as_paid.earn_rates_per_major_unit | list of number, at least 0 | yes | The same rules one by one. To price an amount, round each rate times the amount DOWN on its own and add the results: that is how an order is paid, and one rounding over the sum can promise a point more than the order pays. |
as_paid.welcome_bonus_points | integer, at least 0 | yes | The joining bonus a new shopper is paid, once. 0 when the store pays none, whatever effective.welcomeBonusPoints says. At a store whose welcome and birthday bonuses are paid only in its own app (the programme's effective.bonusesInAppOnly), it is paid only on the store's own app sign-in, never on a web page: do not offer it there as a web sign-up bonus. The platform template's own welcome sentence in ways_to_earn says so. |
as_paid.ways_to_earn | list of object | yes | effective.waysToEarn as a shopper is shown it. The base-rate sentence the app wrote is rebuilt from earn_rate_per_major_unit, and left out when that is 0. On a store built from one of the platform's templates, the template's own sentences about the rate, the app-order bonus and the joining bonus are served as written while the rules still pay what they say, corrected when a figure has moved, and left out when nothing pays them. Entries the merchant wrote are unchanged. |
as_paid.ways_to_earn[].id | string | yes | — |
as_paid.ways_to_earn[].label | string | yes | — |
as_paid.ways_to_earn[].detail | string | yes | — |
audit_id | string, or null | yes | — |
One shape: OfferPreview.
| Field | Type | Always there | What it is |
|---|---|---|---|
object | always offer_preview | yes | — |
offer | object (ApiOffer) | yes | An offer in the store's own days. campaign: extra points per unit between two days. product: points per item of chosen products. channel: extra points per unit on orders from chosen sources. other: set up another way; read-only here. |
offer.id | string | yes | — |
offer.kind | one of campaign, product, channel, other | yes | — |
offer.reason | string | yes | What the shopper is told the points are for. |
offer.read_only | boolean | yes | True for other, and for a campaign that has ended: remove it, but not change it. |
offer.ended | boolean | no | — |
offer.points_per_currency_unit | number | no | — |
offer.starts_at | string | no | — |
offer.ends_at | string | no | — |
offer.starts_on | string | no | The first day, in the store's time zone. |
offer.ends_on | string | no | The last day, in the store's time zone. |
offer.points_per_item | integer | no | — |
offer.product_ids | list of string | no | — |
offer.channel | object | no | Orders from any of these count: a sales channel's source name, an app's id, or an order tag; at most 50 of each, and at least one list not empty. |
offer.channel.source_names | list of string, up to 100 characters, at least 1 character | no | — |
offer.channel.app_ids | list of integer, at least 0 | no | — |
offer.channel.order_tags | list of string, up to 100 characters, at least 1 character | no | — |
dry_run | always true | yes | — |
outcome | one of would_apply, no_change | yes | — |
store_key | string | yes | — |
changed | list of string | yes | The settings that would change, in the engine's names. What each moves from and to is before_after. |
before_after | object (PreviewBeforeAfter) | yes | Every figure the change moves, before and after, in this API's own write field names, worked out from the programme as it would price orders before and after (the same way the storefront works them out). Only the ones that differ are here, so {} means none moves. points_per_currency_unit is the everyday rate an order pays (a running campaign or other offer left out); welcome_bonus_points and birthday_bonus_points are 0 when none is paid; point_value is how many points are worth one unit of the currency; ways_to_earn is the list as a shopper reads it. On a reward's call, reward is the reward itself: null before an add, null after a removal. |
before_after.points_per_currency_unit | object | no | — |
before_after.points_per_currency_unit.before | number, at least 0 | yes | — |
before_after.points_per_currency_unit.after | number, at least 0 | yes | — |
before_after.welcome_bonus_points | object | no | — |
before_after.welcome_bonus_points.before | integer, at least 0 | yes | — |
before_after.welcome_bonus_points.after | integer, at least 0 | yes | — |
before_after.birthday_bonus_points | object | no | — |
before_after.birthday_bonus_points.before | integer, at least 0 | yes | — |
before_after.birthday_bonus_points.after | integer, at least 0 | yes | — |
before_after.point_value | object | no | — |
before_after.point_value.before | object | yes | — |
before_after.point_value.before.points | integer, at least 1 | yes | — |
before_after.point_value.before.amount | always 1.00 | yes | — |
before_after.point_value.after | object | yes | — |
before_after.point_value.after.points | integer, at least 1 | yes | — |
before_after.point_value.after.amount | always 1.00 | yes | — |
before_after.timezone | object | no | — |
before_after.timezone.before | string | yes | — |
before_after.timezone.after | string | yes | — |
before_after.ways_to_earn | object | no | — |
before_after.ways_to_earn.before | list of object | yes | — |
before_after.ways_to_earn.before[].id | string | yes | — |
before_after.ways_to_earn.before[].label | string | yes | — |
before_after.ways_to_earn.before[].detail | string | yes | — |
before_after.ways_to_earn.after | list of object | yes | — |
before_after.ways_to_earn.after[].id | string | yes | — |
before_after.ways_to_earn.after[].label | string | yes | — |
before_after.ways_to_earn.after[].detail | string | yes | — |
before_after.referral | object | no | — |
before_after.referral.before | any, or null or object | yes | — |
before_after.referral.after | any, or null or object | yes | — |
before_after.spend_any_amount | object | no | — |
before_after.spend_any_amount.before | any, or null or object | yes | — |
before_after.spend_any_amount.after | any, or null or object | yes | — |
before_after.reward | object | no | — |
before_after.reward.before | any, or null or object (ApiReward) | yes | — |
before_after.reward.after | any, or null or object (ApiReward) | yes | — |
currency | string, or null | yes | — |
examples | list of object | yes | What a plain order of each size earns now, before and after the change, under every offer running now. |
examples[].order_amount | string | yes | — |
examples[].points_before | integer | yes | — |
examples[].points_after | integer | yes | — |
point_value | object | yes | — |
point_value.points_per_currency_unit_before | integer, or null | yes | — |
point_value.points_per_currency_unit_after | integer, or null | yes | — |
liability | any, or null or object or object | yes | When the point value changes: what the points shoppers hold are worth before and after. Null when it does not change; unavailable when the account has more than one store. |
version_after | string, matching ^[0-9a-f]{32}$, or null | yes | The version the programme would have. |
confirmation | any, or null or object | yes | For a key tagged for an assistant: send token back as the Matrix-Confirmation header with the same request. Null when nothing would change. |
audit_id | string, or null | yes | — |
Headers on the answer: Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset; and, when it applies, Idempotency-Replayed. What each header means.
Example
export STORE='<key of a store from GET /stores>'
export OFFER='<id of an offer from GET /stores/{store_key}/programme/offers>'
curl -X POST "https://app.matrixrewards.co.uk/api/2026-10/stores/$STORE/programme/offers/$OFFER" \
-H "Authorization: Bearer $MATRIX_API_KEY" \
-H "Idempotency-Key: offer-change-2026-09-16-0001" \
-H "Content-Type: application/json" \
-d '{"ends_on": "2026-10-19"}'
The offer, changed:
{
"object": "offer",
"offer": {
"id": "campaign_mfk2q",
"kind": "campaign",
"reason": "Double points week",
"read_only": false,
"ended": false,
"points_per_currency_unit": 1,
"starts_at": "2026-10-05T23:00:00.000Z",
"ends_at": "2026-10-19T23:00:00.000Z",
"starts_on": "2026-10-06",
"ends_on": "2026-10-19"
},
"store_key": "example-store",
"version": "9b2e4c1d7a8f60519283a4b5c6d7e8f5",
"changed": [
"rules"
],
"as_paid": {
"earn_rate_per_major_unit": 1,
"earn_rates_per_major_unit": [
1
],
"welcome_bonus_points": 0,
"ways_to_earn": []
},
"audit_id": "0d1e2f30-4a5b-4c6d-8e7f-000000000018"
}
The same request with ?dry_run=true: nothing changes:
{
"object": "offer_preview",
"offer": {
"id": "campaign_mfk2q",
"kind": "campaign",
"reason": "Double points week",
"read_only": false,
"ended": false,
"points_per_currency_unit": 1,
"starts_at": "2026-10-05T23:00:00.000Z",
"ends_at": "2026-10-19T23:00:00.000Z",
"starts_on": "2026-10-06",
"ends_on": "2026-10-19"
},
"dry_run": true,
"outcome": "would_apply",
"store_key": "example-store",
"changed": [
"rules"
],
"before_after": {},
"currency": "GBP",
"examples": [
{
"order_amount": "10.00",
"points_before": 10,
"points_after": 10
},
{
"order_amount": "50.00",
"points_before": 50,
"points_after": 50
},
{
"order_amount": "100.00",
"points_before": 100,
"points_after": 100
}
],
"point_value": {
"points_per_currency_unit_before": 100,
"points_per_currency_unit_after": 100
},
"liability": null,
"version_after": "9b2e4c1d7a8f60519283a4b5c6d7e8f5",
"confirmation": {
"token": "mct_EXAMPLE.EXAMPLE",
"expires_at": "2026-09-16T10:10:00.000Z"
},
"audit_id": "0d1e2f30-4a5b-4c6d-8e7f-000000000019"
}
Other answers
| Status | What it means | Error pages |
|---|---|---|
| 400 | invalid_request: a parameter or the body could not be read; param names it. | invalid_request |
| 401 | unauthenticated: no usable API key was presented. Malformed, unknown, revoked and wrong-secret are one answer. | unauthenticated |
| 403 | forbidden_scope: the key does not hold write_programme (required_scope says so). test_mode_key: a test key can preview a change and never make one. assistant_limit: the key is an assistant's (every Claude connection, and a key made for an assistant), the merchant has not lifted its fixed limits (/me key.limits_lifted; only a person can lift them, on the app's API keys screen), and the change moves the store's money further than one step. It is measured from the programme as it stood before the assistant's changes began, so steps add up; that starting point holds until assistant_window_hours pass with no assistant change, and a change by the owner to what the programme pays starts afresh (a new label or reason does not; nor does a change by an assistant key whose limits are lifted). What a point is worth, the currency and when points expire never change. What an order earns at most doubles: a new offer counts as paying every order, against what every order earned; and the everyday rate, which still prices some refunds (an API refund that names no order, and an order paid before amounts were recorded), is not lowered to fund an offer (then the figures are the everyday rate's own, least_allowed is the least it may be for every order to earn what it did, up to the field's two decimal places — null when no rate within its own step would do — and on an offer's route no param is named). Every other figure that pays shoppers at most doubles, one at zero stays there, and referrals that are off stay off. Spending any amount that is off stays off; while it is on, the fewest points one code may take and the smallest order it can be used on at most halve, and the most points at most double (a limit cannot be removed); switching it off is never limited. An offer may only narrow who and when it pays. A reward at most doubles what it gives for each point, keeps its kind, and its smallest order at most halves; a new reward stays within one step of a reward of its kind. The preview is refused the same way. problem says which limit; before, asked and most_allowed or least_allowed give the figure in the unit of param, and sending that limit back is accepted. | forbidden_scope, test_mode_key, assistant_limit |
| 404 | not_found: no such member on this account, or (cancelling a code) the member holds no code with that ref. | not_found |
| 409 | idempotency_key_in_use: a request with this key is still running (Retry-After). settings_changed: the programme changed after it was read, so nothing was changed. offer_read_only: the rule was set up in a way the API does not change; change it in the app. offer_ended: the campaign has ended, so it can only be removed. | idempotency_key_in_use, settings_changed, offer_read_only, offer_ended |
| 413 | payload_too_large: the body is over 4,096 bytes. | payload_too_large |
| 422 | idempotency_key_reused: this Idempotency-Key was used for a different request in the last 24 hours. | idempotency_key_reused |
| 428 | confirmation_required: this key is tagged for an assistant; preview with dry_run=true and send the returned confirmation. confirmation_invalid: the confirmation is not for this request, has expired, or is not one we issued (reason). | confirmation_required, confirmation_invalid |
| 429 | rate_limited: too many requests for this key (Retry-After). programme_write_limit: this account has made 100 programme changes through the API in the last 24 hours (limit, used); nothing was changed. | rate_limited, programme_write_limit |
| 503 | change_not_recorded (or access_not_recorded for a preview or a replay): the audit log could not be written, so nothing was changed or returned. Retry with the same Idempotency-Key. | change_not_recorded, access_not_recorded |
Remove an offer
POST /stores/{store_key}/programme/offers/{offer_id}/remove — the full address is https://app.matrixrewards.co.uk/api/2026-10/stores/{store_key}/programme/offers/{offer_id}/remove.
Needs: the write_programme permission (in the app: “Change your programme: the earn rate, the joining and birthday bonuses, what a point is worth, when points expire, the rewards and the offers. Every change is previewable and logged”).
Any campaign, product or channel offer, ended or not. Orders it already paid keep their points. A rule set up another way is read-only (409 offer_read_only).
Parameters
| Name | In | Required | Type | What it is |
|---|---|---|---|---|
store_key | path | yes | string | — |
offer_id | path | yes | string, matching ^[a-z0-9_]{1,64}$ | An offer's id, as listOffers gives it. |
Idempotency-Key | header | yes | string, matching ^[A-Za-z0-9._:-]{1,100}$ | Required on both writes, a points change and a cancel. Pick a new one for each new change and send the same one to retry it. For 24 hours from its first use, a key has three answers: the same request again gets the first answer back, byte for byte, with Idempotency-Replayed: true; a different request under the key is 422 idempotency_key_reused; and while the first request is still running, 409 idempotency_key_in_use with Retry-After. A 5xx does not use the key up: retry with the same one. For a points change the key is also the adjustment's id in the ledger, so the same change is never booked twice, whichever of the account's keys sends it, and even after the 24 hours. |
dry_run | query | no | boolean | true previews the change: every check the real call makes runs, the answer has the status the real call would have, and nothing changes — except one dry_run entry in the audit log, because a preview shows the member's balance. When the change would happen, the preview carries a confirmation for exactly this request. It is the only query parameter a write takes: any other, a misspelling such as dryRun included, is refused with 400 invalid_request naming it in param, and nothing changes. |
Matrix-Confirmation | header | no | string, up to 1,024 characters | The confirmation.token a dry run of this exact request returned. Required for keys tagged for an assistant, except on a replay: a request whose first answer is still kept, or a points change that is already made; checked whenever it is sent. It covers one request — the same key, member, body and Idempotency-Key — for 10 minutes. It proves the change was previewed first; it cannot prove a person saw the preview. |
Request body
Sent as application/json.
| Field | Type | Required | What it is |
|---|---|---|---|
if_version | string, matching ^[0-9a-f]{32}$ | no | The version from a programme read. If the programme has changed since, nothing is changed (409 settings_changed). |
The answer
The change, or with dry_run=true the preview of it. A request already answered is answered again, byte for byte (Idempotency-Replayed).
One shape: OfferRemoved.
| Field | Type | Always there | What it is |
|---|---|---|---|
object | always offer_removed | yes | — |
offer_id | string | yes | — |
store_key | string | yes | — |
version | string, matching ^[0-9a-f]{32}$ | yes | The programme's version after the change. |
changed | list of string | yes | The settings that changed, in the engine's names. Empty when the change asked for what was already set. |
as_paid | object, or null | yes | What a shopper is actually paid, worked out from the rules the same way the storefront and the account page work it out. These are the figures to show a shopper. Null when the store has no programme, which cannot price an order. |
as_paid.earn_rate_per_major_unit | number, at least 0 | yes | Points an order pays per whole unit of the store's currency: the sum of the rules that apply to every order. Conditional bonuses (an app-order bonus, a campaign window, a product rule) are left out, because they hold only for some orders. 0 means nothing pays per unit; say nothing about a rate rather than "earn 0". |
as_paid.earn_rates_per_major_unit | list of number, at least 0 | yes | The same rules one by one. To price an amount, round each rate times the amount DOWN on its own and add the results: that is how an order is paid, and one rounding over the sum can promise a point more than the order pays. |
as_paid.welcome_bonus_points | integer, at least 0 | yes | The joining bonus a new shopper is paid, once. 0 when the store pays none, whatever effective.welcomeBonusPoints says. At a store whose welcome and birthday bonuses are paid only in its own app (the programme's effective.bonusesInAppOnly), it is paid only on the store's own app sign-in, never on a web page: do not offer it there as a web sign-up bonus. The platform template's own welcome sentence in ways_to_earn says so. |
as_paid.ways_to_earn | list of object | yes | effective.waysToEarn as a shopper is shown it. The base-rate sentence the app wrote is rebuilt from earn_rate_per_major_unit, and left out when that is 0. On a store built from one of the platform's templates, the template's own sentences about the rate, the app-order bonus and the joining bonus are served as written while the rules still pay what they say, corrected when a figure has moved, and left out when nothing pays them. Entries the merchant wrote are unchanged. |
as_paid.ways_to_earn[].id | string | yes | — |
as_paid.ways_to_earn[].label | string | yes | — |
as_paid.ways_to_earn[].detail | string | yes | — |
audit_id | string, or null | yes | — |
One shape: OfferRemovedPreview.
| Field | Type | Always there | What it is |
|---|---|---|---|
object | always offer_removed_preview | yes | — |
offer_id | string | yes | — |
dry_run | always true | yes | — |
outcome | one of would_apply, no_change | yes | — |
store_key | string | yes | — |
changed | list of string | yes | The settings that would change, in the engine's names. What each moves from and to is before_after. |
before_after | object (PreviewBeforeAfter) | yes | Every figure the change moves, before and after, in this API's own write field names, worked out from the programme as it would price orders before and after (the same way the storefront works them out). Only the ones that differ are here, so {} means none moves. points_per_currency_unit is the everyday rate an order pays (a running campaign or other offer left out); welcome_bonus_points and birthday_bonus_points are 0 when none is paid; point_value is how many points are worth one unit of the currency; ways_to_earn is the list as a shopper reads it. On a reward's call, reward is the reward itself: null before an add, null after a removal. |
before_after.points_per_currency_unit | object | no | — |
before_after.points_per_currency_unit.before | number, at least 0 | yes | — |
before_after.points_per_currency_unit.after | number, at least 0 | yes | — |
before_after.welcome_bonus_points | object | no | — |
before_after.welcome_bonus_points.before | integer, at least 0 | yes | — |
before_after.welcome_bonus_points.after | integer, at least 0 | yes | — |
before_after.birthday_bonus_points | object | no | — |
before_after.birthday_bonus_points.before | integer, at least 0 | yes | — |
before_after.birthday_bonus_points.after | integer, at least 0 | yes | — |
before_after.point_value | object | no | — |
before_after.point_value.before | object | yes | — |
before_after.point_value.before.points | integer, at least 1 | yes | — |
before_after.point_value.before.amount | always 1.00 | yes | — |
before_after.point_value.after | object | yes | — |
before_after.point_value.after.points | integer, at least 1 | yes | — |
before_after.point_value.after.amount | always 1.00 | yes | — |
before_after.timezone | object | no | — |
before_after.timezone.before | string | yes | — |
before_after.timezone.after | string | yes | — |
before_after.ways_to_earn | object | no | — |
before_after.ways_to_earn.before | list of object | yes | — |
before_after.ways_to_earn.before[].id | string | yes | — |
before_after.ways_to_earn.before[].label | string | yes | — |
before_after.ways_to_earn.before[].detail | string | yes | — |
before_after.ways_to_earn.after | list of object | yes | — |
before_after.ways_to_earn.after[].id | string | yes | — |
before_after.ways_to_earn.after[].label | string | yes | — |
before_after.ways_to_earn.after[].detail | string | yes | — |
before_after.referral | object | no | — |
before_after.referral.before | any, or null or object | yes | — |
before_after.referral.after | any, or null or object | yes | — |
before_after.spend_any_amount | object | no | — |
before_after.spend_any_amount.before | any, or null or object | yes | — |
before_after.spend_any_amount.after | any, or null or object | yes | — |
before_after.reward | object | no | — |
before_after.reward.before | any, or null or object (ApiReward) | yes | — |
before_after.reward.after | any, or null or object (ApiReward) | yes | — |
currency | string, or null | yes | — |
examples | list of object | yes | What a plain order of each size earns now, before and after the change, under every offer running now. |
examples[].order_amount | string | yes | — |
examples[].points_before | integer | yes | — |
examples[].points_after | integer | yes | — |
point_value | object | yes | — |
point_value.points_per_currency_unit_before | integer, or null | yes | — |
point_value.points_per_currency_unit_after | integer, or null | yes | — |
liability | any, or null or object or object | yes | When the point value changes: what the points shoppers hold are worth before and after. Null when it does not change; unavailable when the account has more than one store. |
version_after | string, matching ^[0-9a-f]{32}$, or null | yes | The version the programme would have. |
confirmation | any, or null or object | yes | For a key tagged for an assistant: send token back as the Matrix-Confirmation header with the same request. Null when nothing would change. |
audit_id | string, or null | yes | — |
Headers on the answer: Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset; and, when it applies, Idempotency-Replayed. What each header means.
Example
export STORE='<key of a store from GET /stores>'
export OFFER='<id of an offer from GET /stores/{store_key}/programme/offers>'
curl -X POST "https://app.matrixrewards.co.uk/api/2026-10/stores/$STORE/programme/offers/$OFFER/remove" \
-H "Authorization: Bearer $MATRIX_API_KEY" \
-H "Idempotency-Key: offer-remove-2026-09-16-0001" \
-H "Content-Type: application/json" \
-d '{}'
The offer, removed:
{
"object": "offer_removed",
"offer_id": "campaign_mfk2q",
"store_key": "example-store",
"version": "9b2e4c1d7a8f60519283a4b5c6d7e8f6",
"changed": [
"rules"
],
"as_paid": {
"earn_rate_per_major_unit": 1,
"earn_rates_per_major_unit": [
1
],
"welcome_bonus_points": 0,
"ways_to_earn": []
},
"audit_id": "0d1e2f30-4a5b-4c6d-8e7f-00000000001a"
}
The same request with ?dry_run=true: nothing changes:
{
"object": "offer_removed_preview",
"offer_id": "campaign_mfk2q",
"dry_run": true,
"outcome": "would_apply",
"store_key": "example-store",
"changed": [
"rules"
],
"before_after": {},
"currency": "GBP",
"examples": [
{
"order_amount": "10.00",
"points_before": 10,
"points_after": 10
},
{
"order_amount": "50.00",
"points_before": 50,
"points_after": 50
},
{
"order_amount": "100.00",
"points_before": 100,
"points_after": 100
}
],
"point_value": {
"points_per_currency_unit_before": 100,
"points_per_currency_unit_after": 100
},
"liability": null,
"version_after": "9b2e4c1d7a8f60519283a4b5c6d7e8f6",
"confirmation": {
"token": "mct_EXAMPLE.EXAMPLE",
"expires_at": "2026-09-16T10:10:00.000Z"
},
"audit_id": "0d1e2f30-4a5b-4c6d-8e7f-00000000001b"
}
Other answers
| Status | What it means | Error pages |
|---|---|---|
| 400 | invalid_request: a parameter or the body could not be read; param names it. | invalid_request |
| 401 | unauthenticated: no usable API key was presented. Malformed, unknown, revoked and wrong-secret are one answer. | unauthenticated |
| 403 | forbidden_scope: the key does not hold write_programme (required_scope says so). test_mode_key: a test key can preview a change and never make one. assistant_limit: the key is an assistant's (every Claude connection, and a key made for an assistant), the merchant has not lifted its fixed limits (/me key.limits_lifted; only a person can lift them, on the app's API keys screen), and the change moves the store's money further than one step. It is measured from the programme as it stood before the assistant's changes began, so steps add up; that starting point holds until assistant_window_hours pass with no assistant change, and a change by the owner to what the programme pays starts afresh (a new label or reason does not; nor does a change by an assistant key whose limits are lifted). What a point is worth, the currency and when points expire never change. What an order earns at most doubles: a new offer counts as paying every order, against what every order earned; and the everyday rate, which still prices some refunds (an API refund that names no order, and an order paid before amounts were recorded), is not lowered to fund an offer (then the figures are the everyday rate's own, least_allowed is the least it may be for every order to earn what it did, up to the field's two decimal places — null when no rate within its own step would do — and on an offer's route no param is named). Every other figure that pays shoppers at most doubles, one at zero stays there, and referrals that are off stay off. Spending any amount that is off stays off; while it is on, the fewest points one code may take and the smallest order it can be used on at most halve, and the most points at most double (a limit cannot be removed); switching it off is never limited. An offer may only narrow who and when it pays. A reward at most doubles what it gives for each point, keeps its kind, and its smallest order at most halves; a new reward stays within one step of a reward of its kind. The preview is refused the same way. problem says which limit; before, asked and most_allowed or least_allowed give the figure in the unit of param, and sending that limit back is accepted. | forbidden_scope, test_mode_key, assistant_limit |
| 404 | not_found: no such member on this account, or (cancelling a code) the member holds no code with that ref. | not_found |
| 409 | idempotency_key_in_use: a request with this key is still running (Retry-After). settings_changed: the programme changed after it was read, so nothing was changed. offer_read_only: the rule was set up in a way the API does not change; change it in the app. offer_ended: the campaign has ended, so it can only be removed. | idempotency_key_in_use, settings_changed, offer_read_only, offer_ended |
| 413 | payload_too_large: the body is over 4,096 bytes. | payload_too_large |
| 422 | idempotency_key_reused: this Idempotency-Key was used for a different request in the last 24 hours. | idempotency_key_reused |
| 428 | confirmation_required: this key is tagged for an assistant; preview with dry_run=true and send the returned confirmation. confirmation_invalid: the confirmation is not for this request, has expired, or is not one we issued (reason). | confirmation_required, confirmation_invalid |
| 429 | rate_limited: too many requests for this key (Retry-After). programme_write_limit: this account has made 100 programme changes through the API in the last 24 hours (limit, used); nothing was changed. | rate_limited, programme_write_limit |
| 503 | change_not_recorded (or access_not_recorded for a preview or a replay): the audit log could not be written, so nothing was changed or returned. Retry with the same Idempotency-Key. | change_not_recorded, access_not_recorded |
The earn rules in force at this store
GET /stores/{store_key}/programme/rules — the full address is https://app.matrixrewards.co.uk/api/2026-10/stores/{store_key}/programme/rules.
Needs: the read_programme permission (in the app: “Your programme settings — rates, rewards, rules”). A key with write_programme can call it too.
Parameters
| Name | In | Required | Type | What it is |
|---|---|---|---|---|
store_key | path | yes | string | — |
The answer
The effective rules list.
| Field | Type | Always there | What it is |
|---|---|---|---|
data | list of object, open | yes | — |
has_more | boolean | yes | — |
next_cursor | string, or null | yes | — |
version | string, matching ^[0-9a-f]{32}$ | yes | Which stored programme this is: it changes whenever the store's programme settings change (not its look or expiry), so two reads with the same version saw the same programme. Send it as if_version with a programme change (POST /stores/{store_key}/programme) and the change is refused, 409 settings_changed, if the programme has changed since you read it. The embedded admin's save checks the same version. |
An open object has no fixed set of fields: it is sent as the platform holds it, in its own field names.
Headers on the answer: Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset. What each header means.
Example
export STORE='<key of a store from GET /stores>'
curl "https://app.matrixrewards.co.uk/api/2026-10/stores/$STORE/programme/rules" \
-H "Authorization: Bearer $MATRIX_API_KEY"
The answer:
{
"data": [
{
"id": "base_earn",
"award": {
"type": "perMajorUnit",
"pointsPerUnit": 1
},
"event": "order_paid",
"reason": "Points earned on your order",
"condition": {
"type": "always"
}
}
],
"has_more": false,
"next_cursor": null,
"version": "3f1c9a2b7d4e5f60718293a4b5c6d7e8"
}
Other answers
| Status | What it means | Error pages |
|---|---|---|
| 401 | unauthenticated: no usable API key was presented. Malformed, unknown, revoked and wrong-secret are one answer. | unauthenticated |
| 403 | forbidden_scope: the key does not carry the scope this call needs; required_scope says which. | forbidden_scope |
| 404 | not_found: no such resource on this account — indistinguishable from one that does not exist at all. | not_found |
| 429 | rate_limited: too many requests for this key; bucket says which allowance. Retry-After says how long to wait. | rate_limited |
A store's design: the colours, words and look of the rewards panel, the product-page line and the account page
GET /stores/{store_key}/design — the full address is https://app.matrixrewards.co.uk/api/2026-10/stores/{store_key}/design.
Needs: the read_programme permission (in the app: “Your programme settings — rates, rewards, rules”). A key with write_programme can call it too.
The settings the Design screen in your Shopify admin saves, every one present (anything unusable in what is stored reads as its default, as the shopper surfaces read it), and the design's version for a change to send back as if_version.
Parameters
| Name | In | Required | Type | What it is |
|---|---|---|---|---|
store_key | path | yes | string | — |
The answer
The design.
| Field | Type | Always there | What it is |
|---|---|---|---|
object | always design | yes | — |
store_key | string | yes | — |
version | string, matching ^[0-9a-f]{32}$ | yes | Which design this is: what a design change sends back as if_version. |
design | object (Design) | yes | The Design screen's settings: the colours, words, panel and product-line look, the account page's layout and the terms link, in the names the programme read serves as appearance. |
design.programmeName | string, up to 30 characters | yes | What the programme is called: the page title and the panel heading. Never empty: an empty one reads as "Rewards". |
design.pointsName | string, up to 16 characters | yes | What one point is called, like "star". Never empty. |
design.pointsNamePlural | string, up to 16 characters | yes | The plural, like "stars". Never empty. |
design.heading | string, up to 60 characters | yes | The panel heading when it should differ from programmeName; empty uses programmeName. |
design.storeName | string, up to 60 characters | yes | The store's name as shoppers and merchants read it, wherever they are told which shop: the account page, the shared-balance line, "where else you can spend" and the Matrix Rewards website. Empty uses the name Shopify has for the shop now (GET /stores/{store_key}/brand, from_shopify.name), else the name it was installed with. Control characters are removed and the ends trimmed. |
design.signedOutPrompt | string, up to 140 characters | yes | What a shopper who is not signed in reads. |
design.rewardsHeading | string, up to 60 characters | yes | The rewards section heading; empty shows the section with none. |
design.earnHeading | string, up to 60 characters | yes | The ways-to-earn section heading; empty shows the section with none. |
design.activityHeading | string, up to 60 characters | yes | The recent-activity section heading; empty shows the section with none. |
design.accent | string, matching ^#(?:[0-9a-fA-F]{3}\|[0-9a-fA-F]{6})$, or null | yes | The one colour, like "#4f46e5" (stored in lower case); null follows the theme. |
design.cornerRadius | integer, from 0 to 24, or null | yes | Corner rounding in pixels; null follows the theme, and 0 is square. |
design.density | one of comfortable, compact | yes | — |
design.textScale | one of small, normal, large | yes | The panel's text size beside the text around it. |
design.fontFamily | one of theme, system, serif, mono | yes | The panel's typeface: the theme's, or one of three named stacks. |
design.showRewards | boolean | yes | — |
design.showWaysToEarn | boolean | yes | — |
design.showActivity | boolean | yes | — |
design.showExpiry | boolean | yes | — |
design.showSharedWallet | boolean | yes | The shared-balance line, at an account with more than one store. |
design.showProductPoints | boolean | yes | The "Points on this product" line on product pages, everywhere at once. |
design.showRewardsSite | boolean | yes | A link on the account page to the Matrix Rewards website, where a shopper signs in with their email to see the points they hold at each store that uses Matrix Rewards. Off unless the merchant switches it on. |
design.termsUrl | string, or null | yes | A full https:// address for the programme's terms; null hides the link. |
design.otherShops | object | yes | The shops sharing this store's balance, by store key: whether the account page lists each under "Where else you can spend your points", and the web address its link opens (null: the shop's own Shopify address). A shop that does not share the balance is never listed, whatever is set here. A shop with no entry is listed. Keys are store keys (lowercase letters, digits and hyphens); at most 50. |
design.productWidget | object | yes | The product-page line's look. |
design.productWidget.variant | one of boxed, tinted, plain, badge | yes | — |
design.productWidget.icon | one of none, star, gift, coin | yes | — |
design.productWidget.lines | object | yes | Which of its lines show (each also needs the placed block's own checkbox). |
design.productWidget.lines.earn | boolean | yes | — |
design.productWidget.lines.redeem | boolean | yes | — |
design.productWidget.lines.signin | boolean | yes | — |
design.productWidget.words | object | yes | Its sentences; empty is the default in the shopper's language. [points], [reward] and [amount] are filled in on the page. |
design.productWidget.words.earn | string, up to 80 characters | yes | — |
design.productWidget.words.rate | string, up to 80 characters | yes | — |
design.productWidget.words.ready | string, up to 80 characters | yes | — |
design.productWidget.words.gap | string, up to 80 characters | yes | — |
design.productWidget.words.signin | string, up to 80 characters | yes | — |
design.accountPage | object | yes | The Rewards page in the shopper's Shopify account, in Shopify's own named values: that page takes the store's checkout and accounts branding, so no colour or font is set here. |
design.accountPage.width | one of base, large | yes | — |
design.accountPage.cards | one of outlined, shaded, plain | yes | — |
design.accountPage.corners | one of none, small, base, large | yes | — |
design.accountPage.button | one of primary, secondary | yes | — |
design.accountPage.progress | boolean | yes | A progress bar towards the next reward. |
design.accountPage.order | list of one of codes, rewards, ways, activity, birthday, referral | yes | The sections in order, each once. |
design.panel | object | yes | The storefront rewards panel's look. |
design.panel.variant | one of card, minimal, bold | yes | — |
design.panel.surface | string, matching ^#(?:[0-9a-fA-F]{3}\|[0-9a-fA-F]{6})$, or null | yes | A background colour for the panel; null follows the theme. |
design.panel.layout | one of grid, list | yes | — |
design.panel.words | object | yes | The words on its two links; empty is the default in the shopper's language. |
design.panel.words.signIn | string, up to 40 characters | yes | — |
design.panel.words.use | string, up to 40 characters | yes | — |
design.brand | object (BrandDesign) | yes | The store's branding on the Matrix Rewards website (app.matrixrewards.co.uk): its logos, cover picture, colours and slogan. Each null uses what Shopify has for the shop. Never reaches the storefront or the account page. |
design.brand.logo | string, matching ^(none\|img_[0-9a-f]{32})$, or null | yes | The store's logo. null uses the picture Shopify has (Settings → Brand); none shows none; img_… is a picture uploaded to this store (POST /stores/{store_key}/images). Another store's picture is refused. |
design.brand.squareLogo | string, matching ^(none\|img_[0-9a-f]{32})$, or null | yes | The logo for square places (the store's tile). null uses the picture Shopify has (Settings → Brand); none shows none; img_… is a picture uploaded to this store (POST /stores/{store_key}/images). Another store's picture is refused. |
design.brand.coverImage | string, matching ^(none\|img_[0-9a-f]{32})$, or null | yes | A wide picture for the store's card. null uses the picture Shopify has (Settings → Brand); none shows none; img_… is a picture uploaded to this store (POST /stores/{store_key}/images). Another store's picture is refused. |
design.brand.colour | string, matching ^#(?:[0-9a-fA-F]{3}\|[0-9a-fA-F]{6})$, or null | yes | The brand colour, or null for Shopify's primary brand colour. |
design.brand.textColour | string, matching ^#(?:[0-9a-fA-F]{3}\|[0-9a-fA-F]{6})$, or null | yes | The text colour on it, or null for Shopify's, else worked out. Used only where it is readable on the colour (the WCAG AA contrast for normal text); otherwise black or white is used. |
design.brand.slogan | string, up to 80 characters, or null | yes | A line under the store's name, or null for Shopify's slogan; empty shows none. Up to 80 characters. |
design.brand.font | string, or null | yes | The typeface of the store's name and points on the website, from a fixed list of fonts we host (no family of your own: a page only ever sets one of these). null uses ours, playfair-display; system is the shopper's device font. Shopify has no font setting, so there is nothing of Shopify's to fall back on. One of playfair-display, fraunces, lora, inter, dm-sans, nunito, space-grotesk, system. |
Headers on the answer: Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset. What each header means.
Example
export STORE='<key of a store from GET /stores>'
curl "https://app.matrixrewards.co.uk/api/2026-10/stores/$STORE/design" \
-H "Authorization: Bearer $MATRIX_API_KEY"
The answer:
{
"object": "design",
"store_key": "example-store",
"version": "7c1e2d3f4a5b60718293a4b5c6d7e8f0",
"design": {
"programmeName": "Rewards",
"pointsName": "point",
"pointsNamePlural": "points",
"heading": "",
"storeName": "",
"signedOutPrompt": "Sign in to see your points.",
"rewardsHeading": "What you can get",
"earnHeading": "Ways to earn",
"activityHeading": "Recent activity",
"accent": null,
"cornerRadius": null,
"density": "comfortable",
"textScale": "normal",
"fontFamily": "theme",
"showRewards": true,
"showWaysToEarn": true,
"showActivity": true,
"showExpiry": true,
"showSharedWallet": true,
"showProductPoints": true,
"showRewardsSite": false,
"termsUrl": null,
"otherShops": {},
"productWidget": {
"variant": "boxed",
"icon": "none",
"lines": {
"earn": true,
"redeem": true,
"signin": true
},
"words": {
"earn": "",
"rate": "",
"ready": "",
"gap": "",
"signin": ""
}
},
"accountPage": {
"width": "base",
"cards": "outlined",
"corners": "base",
"button": "primary",
"progress": true,
"order": [
"codes",
"rewards",
"ways",
"activity",
"birthday",
"referral"
]
},
"panel": {
"variant": "card",
"surface": null,
"layout": "grid",
"words": {
"signIn": "",
"use": ""
}
},
"brand": {
"logo": null,
"squareLogo": null,
"coverImage": null,
"colour": null,
"textColour": null,
"slogan": null,
"font": null
}
}
}
Other answers
| Status | What it means | Error pages |
|---|---|---|
| 401 | unauthenticated: no usable API key was presented. Malformed, unknown, revoked and wrong-secret are one answer. | unauthenticated |
| 403 | forbidden_scope: the key does not carry the scope this call needs; required_scope says which. | forbidden_scope |
| 404 | not_found: no such resource on this account — indistinguishable from one that does not exist at all. | not_found |
| 429 | rate_limited: too many requests for this key; bucket says which allowance. Retry-After says how long to wait. | rate_limited |
Change the design: colours, words, the panel and product-line look, the account page layout, the terms link
POST /stores/{store_key}/design — the full address is https://app.matrixrewards.co.uk/api/2026-10/stores/{store_key}/design.
Needs: the write_programme permission (in the app: “Change your programme: the earn rate, the joining and birthday bonuses, what a point is worth, when points expire, the rewards and the offers. Every change is previewable and logged”).
The Design screen's save, through a key: each setting is checked exactly as that screen's save checks it, and a refusal names the setting (param) in the screen's own words. Only the settings sent change. The change is laid over one reading of the design, so a change made elsewhere in between is refused (409 settings_changed), never undone; a change to the programme's rates in between is not. The design it replaces is kept as the Design screen's one step of undo. A preview (dry_run=true) shows each setting that would change, before and after. Asking for the design already set changes nothing. Counts toward the 100 programme changes per account through the API in any 24 hours, and is written to the audit log in the same transaction as the change.
Parameters
| Name | In | Required | Type | What it is |
|---|---|---|---|---|
store_key | path | yes | string | — |
Idempotency-Key | header | yes | string, matching ^[A-Za-z0-9._:-]{1,100}$ | Required on both writes, a points change and a cancel. Pick a new one for each new change and send the same one to retry it. For 24 hours from its first use, a key has three answers: the same request again gets the first answer back, byte for byte, with Idempotency-Replayed: true; a different request under the key is 422 idempotency_key_reused; and while the first request is still running, 409 idempotency_key_in_use with Retry-After. A 5xx does not use the key up: retry with the same one. For a points change the key is also the adjustment's id in the ledger, so the same change is never booked twice, whichever of the account's keys sends it, and even after the 24 hours. |
dry_run | query | no | boolean | true previews the change: every check the real call makes runs, the answer has the status the real call would have, and nothing changes — except one dry_run entry in the audit log, because a preview shows the member's balance. When the change would happen, the preview carries a confirmation for exactly this request. It is the only query parameter a write takes: any other, a misspelling such as dryRun included, is refused with 400 invalid_request naming it in param, and nothing changes. |
Matrix-Confirmation | header | no | string, up to 1,024 characters | The confirmation.token a dry run of this exact request returned. Required for keys tagged for an assistant, except on a replay: a request whose first answer is still kept, or a points change that is already made; checked whenever it is sent. It covers one request — the same key, member, body and Idempotency-Key — for 10 minutes. It proves the change was previewed first; it cannot prove a person saw the preview. |
Request body
Sent as application/json.
| Field | Type | Required | What it is |
|---|---|---|---|
programmeName | string, up to 30 characters | no | What the programme is called: the page title and the panel heading. Never empty: an empty one reads as "Rewards". |
pointsName | string, up to 16 characters | no | What one point is called, like "star". Never empty. |
pointsNamePlural | string, up to 16 characters | no | The plural, like "stars". Never empty. |
heading | string, up to 60 characters | no | The panel heading when it should differ from programmeName; empty uses programmeName. |
storeName | string, up to 60 characters | no | The store's name as shoppers and merchants read it, wherever they are told which shop: the account page, the shared-balance line, "where else you can spend" and the Matrix Rewards website. Empty uses the name Shopify has for the shop now (GET /stores/{store_key}/brand, from_shopify.name), else the name it was installed with. Control characters are removed and the ends trimmed. |
signedOutPrompt | string, up to 140 characters | no | What a shopper who is not signed in reads. |
rewardsHeading | string, up to 60 characters | no | The rewards section heading; empty shows the section with none. |
earnHeading | string, up to 60 characters | no | The ways-to-earn section heading; empty shows the section with none. |
activityHeading | string, up to 60 characters | no | The recent-activity section heading; empty shows the section with none. |
accent | string, matching ^#(?:[0-9a-fA-F]{3}\|[0-9a-fA-F]{6})$, or null | no | The one colour, like "#4f46e5" (stored in lower case); null follows the theme. |
cornerRadius | integer, from 0 to 24, or null | no | Corner rounding in pixels; null follows the theme, and 0 is square. |
density | one of comfortable, compact | no | — |
textScale | one of small, normal, large | no | The panel's text size beside the text around it. |
fontFamily | one of theme, system, serif, mono | no | The panel's typeface: the theme's, or one of three named stacks. |
showRewards | boolean | no | — |
showWaysToEarn | boolean | no | — |
showActivity | boolean | no | — |
showExpiry | boolean | no | — |
showSharedWallet | boolean | no | The shared-balance line, at an account with more than one store. |
showProductPoints | boolean | no | The "Points on this product" line on product pages, everywhere at once. |
showRewardsSite | boolean | no | A link on the account page to the Matrix Rewards website, where a shopper signs in with their email to see the points they hold at each store that uses Matrix Rewards. Off unless the merchant switches it on. |
termsUrl | string, or null | no | A full https:// address for the programme's terms; null or "" hides the link. |
otherShops | object | no | The shops sharing this store's balance, by store key: whether the account page lists each under "Where else you can spend your points", and the web address its link opens (null: the shop's own Shopify address). A shop that does not share the balance is never listed, whatever is set here. A shop with no entry is listed. Sent entries are laid over the stored ones; "" or null for the address uses the Shopify address. Keys are store keys (lowercase letters, digits and hyphens); at most 50. |
productWidget | object | no | The product-page line's look. |
productWidget.variant | one of boxed, tinted, plain, badge | no | — |
productWidget.icon | one of none, star, gift, coin | no | — |
productWidget.lines | object | no | Which of its lines show (each also needs the placed block's own checkbox). |
productWidget.lines.earn | boolean | no | — |
productWidget.lines.redeem | boolean | no | — |
productWidget.lines.signin | boolean | no | — |
productWidget.words | object | no | Its sentences; empty is the default in the shopper's language. [points], [reward] and [amount] are filled in on the page. |
productWidget.words.earn | string, up to 80 characters | no | — |
productWidget.words.rate | string, up to 80 characters | no | — |
productWidget.words.ready | string, up to 80 characters | no | — |
productWidget.words.gap | string, up to 80 characters | no | — |
productWidget.words.signin | string, up to 80 characters | no | — |
accountPage | object | no | The Rewards page in the shopper's Shopify account, in Shopify's own named values: that page takes the store's checkout and accounts branding, so no colour or font is set here. |
accountPage.width | one of base, large | no | — |
accountPage.cards | one of outlined, shaded, plain | no | — |
accountPage.corners | one of none, small, base, large | no | — |
accountPage.button | one of primary, secondary | no | — |
accountPage.progress | boolean | no | A progress bar towards the next reward. |
accountPage.order | list of one of codes, rewards, ways, activity, birthday, referral | no | The sections in order, each once. |
panel | object | no | The storefront rewards panel's look. |
panel.variant | one of card, minimal, bold | no | — |
panel.surface | string, matching ^#(?:[0-9a-fA-F]{3}\|[0-9a-fA-F]{6})$, or null | no | A background colour for the panel; null follows the theme. |
panel.layout | one of grid, list | no | — |
panel.words | object | no | The words on its two links; empty is the default in the shopper's language. |
panel.words.signIn | string, up to 40 characters | no | — |
panel.words.use | string, up to 40 characters | no | — |
if_version | string, matching ^[0-9a-f]{32}$ | no | The version from a design read. If the design has changed since, nothing is changed (409 settings_changed). |
brand | object | no | The branding settings to change, one at a time: { "brand": { "logo": "img_…" } } keeps the rest. |
brand.logo | string, matching ^(none\|img_[0-9a-f]{32})$, or null | no | The store's logo. null uses the picture Shopify has (Settings → Brand); none shows none; img_… is a picture uploaded to this store (POST /stores/{store_key}/images). Another store's picture is refused. |
brand.squareLogo | string, matching ^(none\|img_[0-9a-f]{32})$, or null | no | The logo for square places (the store's tile). null uses the picture Shopify has (Settings → Brand); none shows none; img_… is a picture uploaded to this store (POST /stores/{store_key}/images). Another store's picture is refused. |
brand.coverImage | string, matching ^(none\|img_[0-9a-f]{32})$, or null | no | A wide picture for the store's card. null uses the picture Shopify has (Settings → Brand); none shows none; img_… is a picture uploaded to this store (POST /stores/{store_key}/images). Another store's picture is refused. |
brand.colour | string, matching ^#(?:[0-9a-fA-F]{3}\|[0-9a-fA-F]{6})$, or null | no | The brand colour, or null for Shopify's primary brand colour. |
brand.textColour | string, matching ^#(?:[0-9a-fA-F]{3}\|[0-9a-fA-F]{6})$, or null | no | The text colour on it, or null for Shopify's, else worked out. Used only where it is readable on the colour (the WCAG AA contrast for normal text); otherwise black or white is used. |
brand.slogan | string, up to 80 characters, or null | no | A line under the store's name, or null for Shopify's slogan; empty shows none. Up to 80 characters. |
brand.font | string, or null | no | The typeface of the store's name and points on the website, from a fixed list of fonts we host (no family of your own: a page only ever sets one of these). null uses ours, playfair-display; system is the shopper's device font. Shopify has no font setting, so there is nothing of Shopify's to fall back on. One of playfair-display, fraunces, lora, inter, dm-sans, nunito, space-grotesk, system. |
The answer
The change, or with dry_run=true the preview of it. A request already answered is answered again, byte for byte (Idempotency-Replayed).
One shape: DesignChange.
| Field | Type | Always there | What it is |
|---|---|---|---|
object | always design_change | yes | — |
changed | list of one of programmeName, pointsName, pointsNamePlural, heading, storeName, signedOutPrompt, rewardsHeading, earnHeading, activityHeading, accent, cornerRadius, density, textScale, fontFamily, showRewards, showWaysToEarn, showActivity, showExpiry, showSharedWallet, showProductPoints, showRewardsSite, termsUrl, otherShops, productWidget, accountPage, panel | yes | The settings that changed (a group by its own name). Empty when the change asked for the design already set. |
design | object (Design) | yes | The Design screen's settings: the colours, words, panel and product-line look, the account page's layout and the terms link, in the names the programme read serves as appearance. |
design.programmeName | string, up to 30 characters | yes | What the programme is called: the page title and the panel heading. Never empty: an empty one reads as "Rewards". |
design.pointsName | string, up to 16 characters | yes | What one point is called, like "star". Never empty. |
design.pointsNamePlural | string, up to 16 characters | yes | The plural, like "stars". Never empty. |
design.heading | string, up to 60 characters | yes | The panel heading when it should differ from programmeName; empty uses programmeName. |
design.storeName | string, up to 60 characters | yes | The store's name as shoppers and merchants read it, wherever they are told which shop: the account page, the shared-balance line, "where else you can spend" and the Matrix Rewards website. Empty uses the name Shopify has for the shop now (GET /stores/{store_key}/brand, from_shopify.name), else the name it was installed with. Control characters are removed and the ends trimmed. |
design.signedOutPrompt | string, up to 140 characters | yes | What a shopper who is not signed in reads. |
design.rewardsHeading | string, up to 60 characters | yes | The rewards section heading; empty shows the section with none. |
design.earnHeading | string, up to 60 characters | yes | The ways-to-earn section heading; empty shows the section with none. |
design.activityHeading | string, up to 60 characters | yes | The recent-activity section heading; empty shows the section with none. |
design.accent | string, matching ^#(?:[0-9a-fA-F]{3}\|[0-9a-fA-F]{6})$, or null | yes | The one colour, like "#4f46e5" (stored in lower case); null follows the theme. |
design.cornerRadius | integer, from 0 to 24, or null | yes | Corner rounding in pixels; null follows the theme, and 0 is square. |
design.density | one of comfortable, compact | yes | — |
design.textScale | one of small, normal, large | yes | The panel's text size beside the text around it. |
design.fontFamily | one of theme, system, serif, mono | yes | The panel's typeface: the theme's, or one of three named stacks. |
design.showRewards | boolean | yes | — |
design.showWaysToEarn | boolean | yes | — |
design.showActivity | boolean | yes | — |
design.showExpiry | boolean | yes | — |
design.showSharedWallet | boolean | yes | The shared-balance line, at an account with more than one store. |
design.showProductPoints | boolean | yes | The "Points on this product" line on product pages, everywhere at once. |
design.showRewardsSite | boolean | yes | A link on the account page to the Matrix Rewards website, where a shopper signs in with their email to see the points they hold at each store that uses Matrix Rewards. Off unless the merchant switches it on. |
design.termsUrl | string, or null | yes | A full https:// address for the programme's terms; null hides the link. |
design.otherShops | object | yes | The shops sharing this store's balance, by store key: whether the account page lists each under "Where else you can spend your points", and the web address its link opens (null: the shop's own Shopify address). A shop that does not share the balance is never listed, whatever is set here. A shop with no entry is listed. Keys are store keys (lowercase letters, digits and hyphens); at most 50. |
design.productWidget | object | yes | The product-page line's look. |
design.productWidget.variant | one of boxed, tinted, plain, badge | yes | — |
design.productWidget.icon | one of none, star, gift, coin | yes | — |
design.productWidget.lines | object | yes | Which of its lines show (each also needs the placed block's own checkbox). |
design.productWidget.lines.earn | boolean | yes | — |
design.productWidget.lines.redeem | boolean | yes | — |
design.productWidget.lines.signin | boolean | yes | — |
design.productWidget.words | object | yes | Its sentences; empty is the default in the shopper's language. [points], [reward] and [amount] are filled in on the page. |
design.productWidget.words.earn | string, up to 80 characters | yes | — |
design.productWidget.words.rate | string, up to 80 characters | yes | — |
design.productWidget.words.ready | string, up to 80 characters | yes | — |
design.productWidget.words.gap | string, up to 80 characters | yes | — |
design.productWidget.words.signin | string, up to 80 characters | yes | — |
design.accountPage | object | yes | The Rewards page in the shopper's Shopify account, in Shopify's own named values: that page takes the store's checkout and accounts branding, so no colour or font is set here. |
design.accountPage.width | one of base, large | yes | — |
design.accountPage.cards | one of outlined, shaded, plain | yes | — |
design.accountPage.corners | one of none, small, base, large | yes | — |
design.accountPage.button | one of primary, secondary | yes | — |
design.accountPage.progress | boolean | yes | A progress bar towards the next reward. |
design.accountPage.order | list of one of codes, rewards, ways, activity, birthday, referral | yes | The sections in order, each once. |
design.panel | object | yes | The storefront rewards panel's look. |
design.panel.variant | one of card, minimal, bold | yes | — |
design.panel.surface | string, matching ^#(?:[0-9a-fA-F]{3}\|[0-9a-fA-F]{6})$, or null | yes | A background colour for the panel; null follows the theme. |
design.panel.layout | one of grid, list | yes | — |
design.panel.words | object | yes | The words on its two links; empty is the default in the shopper's language. |
design.panel.words.signIn | string, up to 40 characters | yes | — |
design.panel.words.use | string, up to 40 characters | yes | — |
design.brand | object (BrandDesign) | yes | The store's branding on the Matrix Rewards website (app.matrixrewards.co.uk): its logos, cover picture, colours and slogan. Each null uses what Shopify has for the shop. Never reaches the storefront or the account page. |
design.brand.logo | string, matching ^(none\|img_[0-9a-f]{32})$, or null | yes | The store's logo. null uses the picture Shopify has (Settings → Brand); none shows none; img_… is a picture uploaded to this store (POST /stores/{store_key}/images). Another store's picture is refused. |
design.brand.squareLogo | string, matching ^(none\|img_[0-9a-f]{32})$, or null | yes | The logo for square places (the store's tile). null uses the picture Shopify has (Settings → Brand); none shows none; img_… is a picture uploaded to this store (POST /stores/{store_key}/images). Another store's picture is refused. |
design.brand.coverImage | string, matching ^(none\|img_[0-9a-f]{32})$, or null | yes | A wide picture for the store's card. null uses the picture Shopify has (Settings → Brand); none shows none; img_… is a picture uploaded to this store (POST /stores/{store_key}/images). Another store's picture is refused. |
design.brand.colour | string, matching ^#(?:[0-9a-fA-F]{3}\|[0-9a-fA-F]{6})$, or null | yes | The brand colour, or null for Shopify's primary brand colour. |
design.brand.textColour | string, matching ^#(?:[0-9a-fA-F]{3}\|[0-9a-fA-F]{6})$, or null | yes | The text colour on it, or null for Shopify's, else worked out. Used only where it is readable on the colour (the WCAG AA contrast for normal text); otherwise black or white is used. |
design.brand.slogan | string, up to 80 characters, or null | yes | A line under the store's name, or null for Shopify's slogan; empty shows none. Up to 80 characters. |
design.brand.font | string, or null | yes | The typeface of the store's name and points on the website, from a fixed list of fonts we host (no family of your own: a page only ever sets one of these). null uses ours, playfair-display; system is the shopper's device font. Shopify has no font setting, so there is nothing of Shopify's to fall back on. One of playfair-display, fraunces, lora, inter, dm-sans, nunito, space-grotesk, system. |
store_key | string | yes | — |
version | string, matching ^[0-9a-f]{32}$ | yes | The design's version after the change. |
audit_id | string, or null | yes | — |
One shape: DesignChangePreview.
| Field | Type | Always there | What it is |
|---|---|---|---|
object | always design_change_preview | yes | — |
dry_run | always true | yes | — |
outcome | one of would_apply, no_change | yes | — |
store_key | string | yes | — |
changed | list of one of programmeName, pointsName, pointsNamePlural, heading, storeName, signedOutPrompt, rewardsHeading, earnHeading, activityHeading, accent, cornerRadius, density, textScale, fontFamily, showRewards, showWaysToEarn, showActivity, showExpiry, showSharedWallet, showProductPoints, showRewardsSite, termsUrl, otherShops, productWidget, accountPage, panel | yes | The settings that would change. |
before_after | object | yes | Each setting that would change, whole (a group as a group), before and after. {} when none would. |
before_after.programmeName | object | no | — |
before_after.programmeName.before | string, up to 30 characters | yes | — |
before_after.programmeName.after | string, up to 30 characters | yes | — |
before_after.pointsName | object | no | — |
before_after.pointsName.before | string, up to 16 characters | yes | — |
before_after.pointsName.after | string, up to 16 characters | yes | — |
before_after.pointsNamePlural | object | no | — |
before_after.pointsNamePlural.before | string, up to 16 characters | yes | — |
before_after.pointsNamePlural.after | string, up to 16 characters | yes | — |
before_after.heading | object | no | — |
before_after.heading.before | string, up to 60 characters | yes | — |
before_after.heading.after | string, up to 60 characters | yes | — |
before_after.storeName | object | no | — |
before_after.storeName.before | string, up to 60 characters | yes | — |
before_after.storeName.after | string, up to 60 characters | yes | — |
before_after.signedOutPrompt | object | no | — |
before_after.signedOutPrompt.before | string, up to 140 characters | yes | — |
before_after.signedOutPrompt.after | string, up to 140 characters | yes | — |
before_after.rewardsHeading | object | no | — |
before_after.rewardsHeading.before | string, up to 60 characters | yes | — |
before_after.rewardsHeading.after | string, up to 60 characters | yes | — |
before_after.earnHeading | object | no | — |
before_after.earnHeading.before | string, up to 60 characters | yes | — |
before_after.earnHeading.after | string, up to 60 characters | yes | — |
before_after.activityHeading | object | no | — |
before_after.activityHeading.before | string, up to 60 characters | yes | — |
before_after.activityHeading.after | string, up to 60 characters | yes | — |
before_after.accent | object | no | — |
before_after.accent.before | string, matching ^#(?:[0-9a-fA-F]{3}\|[0-9a-fA-F]{6})$, or null | yes | — |
before_after.accent.after | string, matching ^#(?:[0-9a-fA-F]{3}\|[0-9a-fA-F]{6})$, or null | yes | — |
before_after.cornerRadius | object | no | — |
before_after.cornerRadius.before | integer, from 0 to 24, or null | yes | — |
before_after.cornerRadius.after | integer, from 0 to 24, or null | yes | — |
before_after.density | object | no | — |
before_after.density.before | one of comfortable, compact | yes | — |
before_after.density.after | one of comfortable, compact | yes | — |
before_after.textScale | object | no | — |
before_after.textScale.before | one of small, normal, large | yes | — |
before_after.textScale.after | one of small, normal, large | yes | — |
before_after.fontFamily | object | no | — |
before_after.fontFamily.before | one of theme, system, serif, mono | yes | — |
before_after.fontFamily.after | one of theme, system, serif, mono | yes | — |
before_after.showRewards | object | no | — |
before_after.showRewards.before | boolean | yes | — |
before_after.showRewards.after | boolean | yes | — |
before_after.showWaysToEarn | object | no | — |
before_after.showWaysToEarn.before | boolean | yes | — |
before_after.showWaysToEarn.after | boolean | yes | — |
before_after.showActivity | object | no | — |
before_after.showActivity.before | boolean | yes | — |
before_after.showActivity.after | boolean | yes | — |
before_after.showExpiry | object | no | — |
before_after.showExpiry.before | boolean | yes | — |
before_after.showExpiry.after | boolean | yes | — |
before_after.showSharedWallet | object | no | — |
before_after.showSharedWallet.before | boolean | yes | — |
before_after.showSharedWallet.after | boolean | yes | — |
before_after.showProductPoints | object | no | — |
before_after.showProductPoints.before | boolean | yes | — |
before_after.showProductPoints.after | boolean | yes | — |
before_after.showRewardsSite | object | no | — |
before_after.showRewardsSite.before | boolean | yes | — |
before_after.showRewardsSite.after | boolean | yes | — |
before_after.termsUrl | object | no | — |
before_after.termsUrl.before | string, or null | yes | — |
before_after.termsUrl.after | string, or null | yes | — |
before_after.otherShops | object | no | — |
before_after.otherShops.before | object | yes | The shops sharing this store's balance, by store key: whether the account page lists each under "Where else you can spend your points", and the web address its link opens (null: the shop's own Shopify address). A shop that does not share the balance is never listed, whatever is set here. A shop with no entry is listed. Keys are store keys (lowercase letters, digits and hyphens); at most 50. |
before_after.otherShops.after | object | yes | The shops sharing this store's balance, by store key: whether the account page lists each under "Where else you can spend your points", and the web address its link opens (null: the shop's own Shopify address). A shop that does not share the balance is never listed, whatever is set here. A shop with no entry is listed. Keys are store keys (lowercase letters, digits and hyphens); at most 50. |
before_after.productWidget | object | no | — |
before_after.productWidget.before | object | yes | — |
before_after.productWidget.before.variant | one of boxed, tinted, plain, badge | yes | — |
before_after.productWidget.before.icon | one of none, star, gift, coin | yes | — |
before_after.productWidget.before.lines | object | yes | Which of its lines show (each also needs the placed block's own checkbox). |
before_after.productWidget.before.lines.earn | boolean | yes | — |
before_after.productWidget.before.lines.redeem | boolean | yes | — |
before_after.productWidget.before.lines.signin | boolean | yes | — |
before_after.productWidget.before.words | object | yes | Its sentences; empty is the default in the shopper's language. [points], [reward] and [amount] are filled in on the page. |
before_after.productWidget.before.words.earn | string, up to 80 characters | yes | — |
before_after.productWidget.before.words.rate | string, up to 80 characters | yes | — |
before_after.productWidget.before.words.ready | string, up to 80 characters | yes | — |
before_after.productWidget.before.words.gap | string, up to 80 characters | yes | — |
before_after.productWidget.before.words.signin | string, up to 80 characters | yes | — |
before_after.productWidget.after | object | yes | — |
before_after.productWidget.after.variant | one of boxed, tinted, plain, badge | yes | — |
before_after.productWidget.after.icon | one of none, star, gift, coin | yes | — |
before_after.productWidget.after.lines | object | yes | Which of its lines show (each also needs the placed block's own checkbox). |
before_after.productWidget.after.lines.earn | boolean | yes | — |
before_after.productWidget.after.lines.redeem | boolean | yes | — |
before_after.productWidget.after.lines.signin | boolean | yes | — |
before_after.productWidget.after.words | object | yes | Its sentences; empty is the default in the shopper's language. [points], [reward] and [amount] are filled in on the page. |
before_after.productWidget.after.words.earn | string, up to 80 characters | yes | — |
before_after.productWidget.after.words.rate | string, up to 80 characters | yes | — |
before_after.productWidget.after.words.ready | string, up to 80 characters | yes | — |
before_after.productWidget.after.words.gap | string, up to 80 characters | yes | — |
before_after.productWidget.after.words.signin | string, up to 80 characters | yes | — |
before_after.accountPage | object | no | — |
before_after.accountPage.before | object | yes | — |
before_after.accountPage.before.width | one of base, large | yes | — |
before_after.accountPage.before.cards | one of outlined, shaded, plain | yes | — |
before_after.accountPage.before.corners | one of none, small, base, large | yes | — |
before_after.accountPage.before.button | one of primary, secondary | yes | — |
before_after.accountPage.before.progress | boolean | yes | A progress bar towards the next reward. |
before_after.accountPage.before.order | list of one of codes, rewards, ways, activity, birthday, referral | yes | The sections in order, each once. |
before_after.accountPage.after | object | yes | — |
before_after.accountPage.after.width | one of base, large | yes | — |
before_after.accountPage.after.cards | one of outlined, shaded, plain | yes | — |
before_after.accountPage.after.corners | one of none, small, base, large | yes | — |
before_after.accountPage.after.button | one of primary, secondary | yes | — |
before_after.accountPage.after.progress | boolean | yes | A progress bar towards the next reward. |
before_after.accountPage.after.order | list of one of codes, rewards, ways, activity, birthday, referral | yes | The sections in order, each once. |
before_after.panel | object | no | — |
before_after.panel.before | object | yes | — |
before_after.panel.before.variant | one of card, minimal, bold | yes | — |
before_after.panel.before.surface | string, matching ^#(?:[0-9a-fA-F]{3}\|[0-9a-fA-F]{6})$, or null | yes | A background colour for the panel; null follows the theme. |
before_after.panel.before.layout | one of grid, list | yes | — |
before_after.panel.before.words | object | yes | The words on its two links; empty is the default in the shopper's language. |
before_after.panel.before.words.signIn | string, up to 40 characters | yes | — |
before_after.panel.before.words.use | string, up to 40 characters | yes | — |
before_after.panel.after | object | yes | — |
before_after.panel.after.variant | one of card, minimal, bold | yes | — |
before_after.panel.after.surface | string, matching ^#(?:[0-9a-fA-F]{3}\|[0-9a-fA-F]{6})$, or null | yes | A background colour for the panel; null follows the theme. |
before_after.panel.after.layout | one of grid, list | yes | — |
before_after.panel.after.words | object | yes | The words on its two links; empty is the default in the shopper's language. |
before_after.panel.after.words.signIn | string, up to 40 characters | yes | — |
before_after.panel.after.words.use | string, up to 40 characters | yes | — |
before_after.brand | object | no | — |
before_after.brand.before | object (BrandDesign) | yes | The store's branding on the Matrix Rewards website (app.matrixrewards.co.uk): its logos, cover picture, colours and slogan. Each null uses what Shopify has for the shop. Never reaches the storefront or the account page. |
before_after.brand.before.logo | string, matching ^(none\|img_[0-9a-f]{32})$, or null | yes | The store's logo. null uses the picture Shopify has (Settings → Brand); none shows none; img_… is a picture uploaded to this store (POST /stores/{store_key}/images). Another store's picture is refused. |
before_after.brand.before.squareLogo | string, matching ^(none\|img_[0-9a-f]{32})$, or null | yes | The logo for square places (the store's tile). null uses the picture Shopify has (Settings → Brand); none shows none; img_… is a picture uploaded to this store (POST /stores/{store_key}/images). Another store's picture is refused. |
before_after.brand.before.coverImage | string, matching ^(none\|img_[0-9a-f]{32})$, or null | yes | A wide picture for the store's card. null uses the picture Shopify has (Settings → Brand); none shows none; img_… is a picture uploaded to this store (POST /stores/{store_key}/images). Another store's picture is refused. |
before_after.brand.before.colour | string, matching ^#(?:[0-9a-fA-F]{3}\|[0-9a-fA-F]{6})$, or null | yes | The brand colour, or null for Shopify's primary brand colour. |
before_after.brand.before.textColour | string, matching ^#(?:[0-9a-fA-F]{3}\|[0-9a-fA-F]{6})$, or null | yes | The text colour on it, or null for Shopify's, else worked out. Used only where it is readable on the colour (the WCAG AA contrast for normal text); otherwise black or white is used. |
before_after.brand.before.slogan | string, up to 80 characters, or null | yes | A line under the store's name, or null for Shopify's slogan; empty shows none. Up to 80 characters. |
before_after.brand.before.font | string, or null | yes | The typeface of the store's name and points on the website, from a fixed list of fonts we host (no family of your own: a page only ever sets one of these). null uses ours, playfair-display; system is the shopper's device font. Shopify has no font setting, so there is nothing of Shopify's to fall back on. One of playfair-display, fraunces, lora, inter, dm-sans, nunito, space-grotesk, system. |
before_after.brand.after | object (BrandDesign) | yes | The store's branding on the Matrix Rewards website (app.matrixrewards.co.uk): its logos, cover picture, colours and slogan. Each null uses what Shopify has for the shop. Never reaches the storefront or the account page. |
before_after.brand.after.logo | string, matching ^(none\|img_[0-9a-f]{32})$, or null | yes | The store's logo. null uses the picture Shopify has (Settings → Brand); none shows none; img_… is a picture uploaded to this store (POST /stores/{store_key}/images). Another store's picture is refused. |
before_after.brand.after.squareLogo | string, matching ^(none\|img_[0-9a-f]{32})$, or null | yes | The logo for square places (the store's tile). null uses the picture Shopify has (Settings → Brand); none shows none; img_… is a picture uploaded to this store (POST /stores/{store_key}/images). Another store's picture is refused. |
before_after.brand.after.coverImage | string, matching ^(none\|img_[0-9a-f]{32})$, or null | yes | A wide picture for the store's card. null uses the picture Shopify has (Settings → Brand); none shows none; img_… is a picture uploaded to this store (POST /stores/{store_key}/images). Another store's picture is refused. |
before_after.brand.after.colour | string, matching ^#(?:[0-9a-fA-F]{3}\|[0-9a-fA-F]{6})$, or null | yes | The brand colour, or null for Shopify's primary brand colour. |
before_after.brand.after.textColour | string, matching ^#(?:[0-9a-fA-F]{3}\|[0-9a-fA-F]{6})$, or null | yes | The text colour on it, or null for Shopify's, else worked out. Used only where it is readable on the colour (the WCAG AA contrast for normal text); otherwise black or white is used. |
before_after.brand.after.slogan | string, up to 80 characters, or null | yes | A line under the store's name, or null for Shopify's slogan; empty shows none. Up to 80 characters. |
before_after.brand.after.font | string, or null | yes | The typeface of the store's name and points on the website, from a fixed list of fonts we host (no family of your own: a page only ever sets one of these). null uses ours, playfair-display; system is the shopper's device font. Shopify has no font setting, so there is nothing of Shopify's to fall back on. One of playfair-display, fraunces, lora, inter, dm-sans, nunito, space-grotesk, system. |
version_after | string, matching ^[0-9a-f]{32}$, or null | yes | The version the design would have. |
confirmation | any, or null or object | yes | For a key tagged for an assistant: send token back as the Matrix-Confirmation header with the same request. Null when nothing would change. |
audit_id | string, or null | yes | — |
Headers on the answer: Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset; and, when it applies, Idempotency-Replayed. What each header means.
Example
export STORE='<key of a store from GET /stores>'
curl -X POST "https://app.matrixrewards.co.uk/api/2026-10/stores/$STORE/design" \
-H "Authorization: Bearer $MATRIX_API_KEY" \
-H "Idempotency-Key: design-2026-09-16-0001" \
-H "Content-Type: application/json" \
-d '{"accent": "#4f46e5", "panel": {"variant": "bold"}}'
The change, made:
{
"object": "design_change",
"changed": [
"accent",
"panel"
],
"design": {
"programmeName": "Rewards",
"pointsName": "point",
"pointsNamePlural": "points",
"heading": "",
"storeName": "",
"signedOutPrompt": "Sign in to see your points.",
"rewardsHeading": "What you can get",
"earnHeading": "Ways to earn",
"activityHeading": "Recent activity",
"accent": "#4f46e5",
"cornerRadius": null,
"density": "comfortable",
"textScale": "normal",
"fontFamily": "theme",
"showRewards": true,
"showWaysToEarn": true,
"showActivity": true,
"showExpiry": true,
"showSharedWallet": true,
"showProductPoints": true,
"showRewardsSite": false,
"termsUrl": null,
"otherShops": {},
"productWidget": {
"variant": "boxed",
"icon": "none",
"lines": {
"earn": true,
"redeem": true,
"signin": true
},
"words": {
"earn": "",
"rate": "",
"ready": "",
"gap": "",
"signin": ""
}
},
"accountPage": {
"width": "base",
"cards": "outlined",
"corners": "base",
"button": "primary",
"progress": true,
"order": [
"codes",
"rewards",
"ways",
"activity",
"birthday",
"referral"
]
},
"panel": {
"variant": "bold",
"surface": null,
"layout": "grid",
"words": {
"signIn": "",
"use": ""
}
},
"brand": {
"logo": null,
"squareLogo": null,
"coverImage": null,
"colour": null,
"textColour": null,
"slogan": null,
"font": null
}
},
"store_key": "example-store",
"version": "7c1e2d3f4a5b60718293a4b5c6d7e8f1",
"audit_id": "0d1e2f30-4a5b-4c6d-8e7f-000000000020"
}
The same request with ?dry_run=true: nothing changes:
{
"object": "design_change_preview",
"dry_run": true,
"outcome": "would_apply",
"store_key": "example-store",
"changed": [
"accent",
"panel"
],
"before_after": {
"accent": {
"before": null,
"after": "#4f46e5"
},
"panel": {
"before": {
"variant": "card",
"surface": null,
"layout": "grid",
"words": {
"signIn": "",
"use": ""
}
},
"after": {
"variant": "bold",
"surface": null,
"layout": "grid",
"words": {
"signIn": "",
"use": ""
}
}
}
},
"version_after": "7c1e2d3f4a5b60718293a4b5c6d7e8f1",
"confirmation": {
"token": "mct_EXAMPLE.EXAMPLE",
"expires_at": "2026-09-16T10:10:00.000Z"
},
"audit_id": "0d1e2f30-4a5b-4c6d-8e7f-000000000021"
}
Other answers
| Status | What it means | Error pages |
|---|---|---|
| 400 | invalid_request: a parameter or the body could not be read; param names it. | invalid_request |
| 401 | unauthenticated: no usable API key was presented. Malformed, unknown, revoked and wrong-secret are one answer. | unauthenticated |
| 403 | forbidden_scope: the key does not hold write_programme (required_scope says so). test_mode_key: a test key can preview a change and never make one. | forbidden_scope, test_mode_key |
| 404 | not_found: no store with that key on this account. | not_found |
| 409 | idempotency_key_in_use: a request with this key is still running (Retry-After). settings_changed: the design changed after it was read — by if_version, or by the reading this change was built over — so nothing was changed; read it again and resend. | idempotency_key_in_use, settings_changed |
| 413 | payload_too_large: the body is over 4,096 bytes. | payload_too_large |
| 422 | idempotency_key_reused: this Idempotency-Key was used for a different request in the last 24 hours. | idempotency_key_reused |
| 428 | confirmation_required: this key is tagged for an assistant; preview with dry_run=true and send the returned confirmation. confirmation_invalid: the confirmation is not for this request, has expired, or is not one we issued (reason). | confirmation_required, confirmation_invalid |
| 429 | rate_limited: too many requests for this key (Retry-After). programme_write_limit: this account has made 100 programme changes through the API in the last 24 hours (limit, used); nothing was changed. | rate_limited, programme_write_limit |
| 503 | change_not_recorded (or access_not_recorded for a preview or a replay): the audit log could not be written, so nothing was changed or returned. Retry with the same Idempotency-Key. | change_not_recorded, access_not_recorded |
A store's name and branding as the Matrix Rewards website shows them, and what Shopify has
GET /stores/{store_key}/brand — the full address is https://app.matrixrewards.co.uk/api/2026-10/stores/{store_key}/brand.
Needs: the read_programme permission (in the app: “Your programme settings — rates, rewards, rules”). A key with write_programme can call it too.
The finished name, logos, cover, colours and slogan (each the design's setting, else Shopify's), what Shopify has for the shop, and the design's branding settings. Change them with POST /stores/{store_key}/design (storeName, brand).
Parameters
| Name | In | Required | Type | What it is |
|---|---|---|---|---|
store_key | path | yes | string | — |
The answer
The branding.
| Field | Type | Always there | What it is |
|---|---|---|---|
object | one of store_branding | yes | — |
store_key | string | yes | — |
name | string | yes | — |
slogan | string, or null | yes | — |
logo_url | string, or null | yes | — |
square_logo_url | string, or null | yes | — |
cover_image_url | string, or null | yes | — |
colour | string, matching ^#(?:[0-9a-fA-F]{3}\|[0-9a-fA-F]{6})$, or null | yes | — |
text_colour | string, matching ^#(?:[0-9a-fA-F]{3}\|[0-9a-fA-F]{6})$, or null | yes | Readable on colour (WCAG AA contrast for normal text). |
font | string | yes | The typeface the website draws the name and points in: the design's choice, else playfair-display. One of playfair-display, fraunces, lora, inter, dm-sans, nunito, space-grotesk, system. |
from_shopify | object (ShopifyBrand) | yes | What Shopify has for the shop: its name and its brand settings (Settings → Brand), read without a key and kept, again at most once a day. Picture addresses are Shopify's CDN only; anything else Shopify sent is left out. |
from_shopify.name | string, or null | yes | — |
from_shopify.slogan | string, or null | yes | — |
from_shopify.shortDescription | string, or null | yes | — |
from_shopify.logo | string, or null | yes | — |
from_shopify.squareLogo | string, or null | yes | — |
from_shopify.coverImage | string, or null | yes | — |
from_shopify.colour | string, matching ^#(?:[0-9a-fA-F]{3}\|[0-9a-fA-F]{6})$, or null | yes | — |
from_shopify.textColour | string, matching ^#(?:[0-9a-fA-F]{3}\|[0-9a-fA-F]{6})$, or null | yes | — |
shopify_read_at | string, or null | yes | When Shopify was last read for this store; null before the first read. |
design | object (BrandDesign) | yes | The store's branding on the Matrix Rewards website (app.matrixrewards.co.uk): its logos, cover picture, colours and slogan. Each null uses what Shopify has for the shop. Never reaches the storefront or the account page. |
design.logo | string, matching ^(none\|img_[0-9a-f]{32})$, or null | yes | The store's logo. null uses the picture Shopify has (Settings → Brand); none shows none; img_… is a picture uploaded to this store (POST /stores/{store_key}/images). Another store's picture is refused. |
design.squareLogo | string, matching ^(none\|img_[0-9a-f]{32})$, or null | yes | The logo for square places (the store's tile). null uses the picture Shopify has (Settings → Brand); none shows none; img_… is a picture uploaded to this store (POST /stores/{store_key}/images). Another store's picture is refused. |
design.coverImage | string, matching ^(none\|img_[0-9a-f]{32})$, or null | yes | A wide picture for the store's card. null uses the picture Shopify has (Settings → Brand); none shows none; img_… is a picture uploaded to this store (POST /stores/{store_key}/images). Another store's picture is refused. |
design.colour | string, matching ^#(?:[0-9a-fA-F]{3}\|[0-9a-fA-F]{6})$, or null | yes | The brand colour, or null for Shopify's primary brand colour. |
design.textColour | string, matching ^#(?:[0-9a-fA-F]{3}\|[0-9a-fA-F]{6})$, or null | yes | The text colour on it, or null for Shopify's, else worked out. Used only where it is readable on the colour (the WCAG AA contrast for normal text); otherwise black or white is used. |
design.slogan | string, up to 80 characters, or null | yes | A line under the store's name, or null for Shopify's slogan; empty shows none. Up to 80 characters. |
design.font | string, or null | yes | The typeface of the store's name and points on the website, from a fixed list of fonts we host (no family of your own: a page only ever sets one of these). null uses ours, playfair-display; system is the shopper's device font. Shopify has no font setting, so there is nothing of Shopify's to fall back on. One of playfair-display, fraunces, lora, inter, dm-sans, nunito, space-grotesk, system. |
Headers on the answer: Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset. What each header means.
Example
export STORE='<key of a store from GET /stores>'
curl "https://app.matrixrewards.co.uk/api/2026-10/stores/$STORE/brand" \
-H "Authorization: Bearer $MATRIX_API_KEY"
The answer:
{
"object": "store_branding",
"store_key": "example-store",
"name": "Example Store",
"slogan": "Small batch, made well",
"logo_url": "https://app.matrixrewards.co.uk/brand-images/img_3f9a1c0e5b7d42a8916c0d2e4f6a8b1c",
"square_logo_url": "https://cdn.shopify.com/s/files/1/0000/0001/files/square.png",
"cover_image_url": null,
"colour": "#1a5c5c",
"text_colour": "#ffffff",
"font": "playfair-display",
"from_shopify": {
"name": "Example Store",
"slogan": "Small batch, made well",
"shortDescription": null,
"logo": null,
"squareLogo": "https://cdn.shopify.com/s/files/1/0000/0001/files/square.png",
"coverImage": null,
"colour": "#1a5c5c",
"textColour": "#ffffff"
},
"shopify_read_at": "2026-09-16T10:00:00Z",
"design": {
"logo": "img_3f9a1c0e5b7d42a8916c0d2e4f6a8b1c",
"squareLogo": null,
"coverImage": null,
"colour": null,
"textColour": null,
"slogan": null,
"font": null
}
}
Other answers
| Status | What it means | Error pages |
|---|---|---|
| 401 | unauthenticated: no usable API key was presented. Malformed, unknown, revoked and wrong-secret are one answer. | unauthenticated |
| 403 | forbidden_scope: the key does not carry the scope this call needs; required_scope says which. | forbidden_scope |
| 404 | not_found: no such resource on this account — indistinguishable from one that does not exist at all. | not_found |
| 429 | rate_limited: too many requests for this key; bucket says which allowance. Retry-After says how long to wait. | rate_limited |
The pictures uploaded to a store, newest first
GET /stores/{store_key}/images — the full address is https://app.matrixrewards.co.uk/api/2026-10/stores/{store_key}/images.
Needs: the read_programme permission (in the app: “Your programme settings — rates, rewards, rules”). A key with write_programme can call it too.
Every picture kept for the store (at most 20), for the design's brand settings to name.
Parameters
| Name | In | Required | Type | What it is |
|---|---|---|---|---|
store_key | path | yes | string | — |
The answer
The pictures.
| Field | Type | Always there | What it is |
|---|---|---|---|
data | list of object (BrandImage) | yes | — |
data[].object | one of brand_image | yes | — |
data[].id | string, matching ^img_[0-9a-f]{32}$ | yes | What the design's brand.logo, brand.squareLogo or brand.coverImage takes. |
data[].store_key | string | yes | — |
data[].content_type | one of image/png, image/jpeg, image/webp | yes | — |
data[].width | integer | yes | — |
data[].height | integer | yes | — |
data[].bytes | integer | yes | — |
data[].source | one of upload, shopify_files | yes | — |
data[].url | string | yes | Where the picture is served, to anyone, for good. |
data[].created_at | string | yes | — |
has_more | one of false | yes | — |
next_cursor | any, or null | yes | — |
Headers on the answer: Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset. What each header means.
Example
export STORE='<key of a store from GET /stores>'
curl "https://app.matrixrewards.co.uk/api/2026-10/stores/$STORE/images" \
-H "Authorization: Bearer $MATRIX_API_KEY"
The answer:
{
"data": [
{
"object": "brand_image",
"id": "img_3f9a1c0e5b7d42a8916c0d2e4f6a8b1c",
"store_key": "example-store",
"content_type": "image/png",
"width": 512,
"height": 512,
"bytes": 24816,
"source": "upload",
"url": "https://app.matrixrewards.co.uk/brand-images/img_3f9a1c0e5b7d42a8916c0d2e4f6a8b1c",
"created_at": "2026-09-16T10:00:00Z"
}
],
"has_more": false,
"next_cursor": null
}
Other answers
| Status | What it means | Error pages |
|---|---|---|
| 401 | unauthenticated: no usable API key was presented. Malformed, unknown, revoked and wrong-secret are one answer. | unauthenticated |
| 403 | forbidden_scope: the key does not carry the scope this call needs; required_scope says which. | forbidden_scope |
| 404 | not_found: no such resource on this account — indistinguishable from one that does not exist at all. | not_found |
| 429 | rate_limited: too many requests for this key; bucket says which allowance. Retry-After says how long to wait. | rate_limited |
Upload a picture for the store's branding (a logo, a square logo, a cover)
POST /stores/{store_key}/images — the full address is https://app.matrixrewards.co.uk/api/2026-10/stores/{store_key}/images.
Needs: the write_programme permission (in the app: “Change your programme: the earn rate, the joining and birthday bonuses, what a point is worth, when points expire, the rewards and the offers. Every change is previewable and logged”).
Send the file itself (Content-Type: image/png, image/jpeg or image/webp, up to 524,288 bytes, 16 to 4096 pixels a side), or JSON naming a picture in your Shopify Files (source_url, https://cdn.shopify.com/ only). The type is read from the file's own bytes; SVG and anything else are refused. The same picture twice is the same picture (created: false). Then name its id in the design: POST /stores/{store_key}/design with { "brand": { "logo": "img_…" } }. A store keeps at most 20; the oldest the design does not use makes room.
Parameters
| Name | In | Required | Type | What it is |
|---|---|---|---|---|
store_key | path | yes | string | — |
Request body
Sent as application/json.
| Field | Type | Required | What it is |
|---|---|---|---|
source_url | string | yes | A picture in your Shopify Files: an https://cdn.shopify.com/ address. No other address is fetched. |
The answer
Kept (created: true), or the same picture already kept (created: false).
| Field | Type | Always there | What it is |
|---|---|---|---|
object | one of brand_image | yes | — |
id | string, matching ^img_[0-9a-f]{32}$ | yes | What the design's brand.logo, brand.squareLogo or brand.coverImage takes. |
store_key | string | yes | — |
content_type | one of image/png, image/jpeg, image/webp | yes | — |
width | integer | yes | — |
height | integer | yes | — |
bytes | integer | yes | — |
source | one of upload, shopify_files | yes | — |
url | string | yes | Where the picture is served, to anyone, for good. |
created_at | string | yes | — |
created | boolean | yes | False when the same picture was already kept for the store (nothing new was stored). |
Headers on the answer: Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset. What each header means.
Example
export STORE='<key of a store from GET /stores>'
curl -X POST "https://app.matrixrewards.co.uk/api/2026-10/stores/$STORE/images" \
-H "Authorization: Bearer $MATRIX_API_KEY"
The answer:
{
"object": "brand_image",
"id": "img_3f9a1c0e5b7d42a8916c0d2e4f6a8b1c",
"store_key": "example-store",
"content_type": "image/png",
"width": 512,
"height": 512,
"bytes": 24816,
"source": "upload",
"url": "https://app.matrixrewards.co.uk/brand-images/img_3f9a1c0e5b7d42a8916c0d2e4f6a8b1c",
"created_at": "2026-09-16T10:00:00Z",
"created": true
}
Other answers
| Status | What it means | Error pages |
|---|---|---|
| 400 | invalid_request: a parameter or the body could not be read; param names it. | invalid_request |
| 401 | unauthenticated: no usable API key was presented. Malformed, unknown, revoked and wrong-secret are one answer. | unauthenticated |
| 403 | forbidden_scope: the key does not hold write_programme (required_scope says so). test_mode_key: a test key keeps nothing. | forbidden_scope, test_mode_key |
| 404 | not_found: no such resource on this account — indistinguishable from one that does not exist at all. | not_found |
| 409 | too_many_images: the store keeps at most 20 pictures and the design uses every one. | too_many_images |
| 413 | payload_too_large: the picture is over 524,288 bytes, or the JSON body over 4,096 bytes. | payload_too_large |
| 422 | invalid_image: not a PNG, JPEG or WebP by its own bytes, or under 16 or over 4096 pixels on a side; or sent with another Content-Type. | invalid_image |
| 429 | rate_limited: too many requests for this key; bucket says which allowance. Retry-After says how long to wait. | rate_limited |
| 502 | shopify_file_unavailable: Shopify did not send the picture at source_url. Nothing was kept; try again, or upload the file. | shopify_file_unavailable |
| 503 | change_not_recorded: the picture was kept but the audit log could not be written. Send it again: the same picture is not kept twice. | change_not_recorded |