Webhooks
Where every points movement is sent, and a way to check a receiver. Managing webhooks needs write_webhooks, and a key for an AI assistant or a test key can never do it: an endpoint is a standing channel out of the account. Each event is POSTed as JSON to every enabled endpoint subscribed to its type, signed the Standard Webhooks way: webhook-id (the same on every retry, and the thing to de-duplicate on), webhook-timestamp (unix seconds of this attempt; reject one more than 5 minutes from your clock), and webhook-signature: v1,<base64 HMAC-SHA256> over <webhook-id>.<webhook-timestamp>.<body>, keyed by the base64 after whsec_ in the endpoint's secret, decoded to bytes. Verify the raw body, before parsing it. Answer 2xx within 15 seconds; anything else, a redirect, or no answer is a failure, and the event is sent again after 5 seconds, 5 minutes, 30 minutes, then 2, 5, 10, 14, 20 and 24 hours (each ±10%) — ten attempts over about 75 hours — with the same id and the same bytes. Events can arrive out of order: data.ledger_entry_id and data.created_at order them. An endpoint that has failed 10 deliveries in a row AND been failing for at least 24 hours is disabled: nothing more is sent to it, what was waiting is abandoned, webhook.disabled is written to the audit log, and GET /webhooks shows status: disabled. Delete it and create it again once the receiver works. An endpoint receives the events recorded after it was created.
Part of the API reference, version 2026-10. Every address below starts with the base address:
https://app.matrixrewards.co.uk/api/2026-10
The webhook endpoints on this account, and why any stopped
GET /webhooks — the full address is https://app.matrixrewards.co.uk/api/2026-10/webhooks.
Needs: the write_webhooks permission (in the app: “Send every change to members’ points, by member key, to a web address the key chooses (webhooks). Not for an assistant key”).
Deleted endpoints are not listed. Needs write_webhooks, because an endpoint's URL can carry the merchant's own token. Written to the audit log (webhooks.list) before it answers.
The answer
The endpoints.
| Field | Type | Always there | What it is |
|---|---|---|---|
data | list of object (WebhookEndpoint) | yes | — |
data[].object | one of webhook_endpoint | yes | — |
data[].id | string | yes | — |
data[].url | string | yes | — |
data[].event_types | list of WebhookEventType values | yes | — |
data[].description | string, or null | yes | — |
data[].status | one of enabled, disabled | yes | disabled: it failed too often (disabled_reason), and nothing more is sent to it. Delete it and create it again to start over. |
data[].created_at | string | yes | — |
data[].disabled_at | string, or null | yes | — |
data[].disabled_reason | one of failing, or null | yes | — |
data[].consecutive_failures | integer, at least 0 | yes | Failed deliveries in a row since the last success. |
data[].failing_since | string, or null | yes | When the current run of failures began; null after a success. |
data[].last_success_at | string, or null | yes | — |
data[].last_failure_at | string, or null | yes | — |
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/webhooks" \
-H "Authorization: Bearer $MATRIX_API_KEY"
The answer:
{
"data": [
{
"object": "webhook_endpoint",
"id": "we_Q3mN8xR2vT6pL9wK4sJ7hF1d",
"url": "https://hooks.example.com/matrix-rewards",
"event_types": [
"points.earned",
"points.adjusted"
],
"description": "Our CRM",
"status": "enabled",
"created_at": "2026-09-16T10:00:00.000Z",
"disabled_at": null,
"disabled_reason": null,
"consecutive_failures": 0,
"failing_since": null,
"last_success_at": null,
"last_failure_at": null
}
],
"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 hold write_webhooks (required_scope says so). forbidden_for_assistant: a key made for an AI assistant can never manage webhooks. test_mode_key: nor can a test key — an endpoint sends real data out. previous_secret (creating an endpoint only): the request was signed with the key's previous secret, which still works while a rotation's grace lasts; create endpoints with the current secret. | forbidden_scope, forbidden_for_assistant, test_mode_key, previous_secret |
| 429 | rate_limited: too many requests for this key; bucket says which allowance. Retry-After says how long to wait. | rate_limited |
| 503 | access_not_recorded: the read could not be written to the account's audit log, so no data was returned. | access_not_recorded |
Send points movements to an https endpoint
POST /webhooks — the full address is https://app.matrixrewards.co.uk/api/2026-10/webhooks.
Needs: the write_webhooks permission (in the app: “Send every change to members’ points, by member key, to a web address the key chooses (webhooks). Not for an assistant key”).
Returns the endpoint and its signing secret, once. At most 5 enabled endpoints per account. The endpoint receives the events it subscribes to from the moment it is created — not before. Takes no Idempotency-Key: a stored answer would have to keep the secret, so a retry after a lost answer makes a second endpoint, which shows in the list and can be deleted. The endpoint and its audit entry (webhook.create, recording the URL's host only) are one transaction. The endpoint lasts as long as the key that created it: revoking that key, or ending it with the website seat that made it, deletes the endpoint too (webhook.delete, with the reason and the key's id); so does the signing out everywhere, or removal, of the website seat whose secret created it, whatever has become of the key since. A key's previous secret, still working while a rotation's grace lasts, cannot create one.
Request body
Sent as application/json.
| Field | Type | Required | What it is |
|---|---|---|---|
url | string, up to 2,048 characters | yes | https on the standard port, no user:password, no #fragment, and a public address: a name that resolves only to public addresses, or a public IP. Refused with param: url and a reason: not_a_url, too_long, not_https, port_not_allowed, has_credentials, has_fragment, blocked_name (localhost, *.local, *.internal, *.arpa, a name with no dot), own_host, blocked_address (private, loopback, link-local, metadata or other reserved — including when any one of a name's addresses is), does_not_resolve. The same checks run again before every delivery, and a redirect is never followed. |
event_types | list of WebhookEventType values, at least 1 | yes | — |
description | string, up to 120 characters, or null | no | — |
The answer
The endpoint, with its secret.
| Field | Type | Always there | What it is |
|---|---|---|---|
object | one of webhook_endpoint | yes | — |
id | string | yes | — |
url | string | yes | — |
event_types | list of WebhookEventType values | yes | — |
description | string, or null | yes | — |
status | one of enabled, disabled | yes | disabled: it failed too often (disabled_reason), and nothing more is sent to it. Delete it and create it again to start over. |
created_at | string | yes | — |
disabled_at | string, or null | yes | — |
disabled_reason | one of failing, or null | yes | — |
consecutive_failures | integer, at least 0 | yes | Failed deliveries in a row since the last success. |
failing_since | string, or null | yes | When the current run of failures began; null after a success. |
last_success_at | string, or null | yes | — |
last_failure_at | string, or null | yes | — |
secret | string, matching ^whsec_[A-Za-z0-9+/]+={0,2}$ | yes | The signing secret, shown ONCE, here. The HMAC key is the base64 part after whsec_, decoded to bytes. It cannot be shown again; a lost secret means deleting the endpoint and creating it again. |
Headers on the answer: Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset. What each header means.
Example
curl -X POST "https://app.matrixrewards.co.uk/api/2026-10/webhooks" \
-H "Authorization: Bearer $MATRIX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://hooks.example.com/matrix-rewards", "event_types": ["points.earned", "points.adjusted"], "description": "Our CRM"}'
The answer:
{
"object": "webhook_endpoint",
"id": "we_Q3mN8xR2vT6pL9wK4sJ7hF1d",
"url": "https://hooks.example.com/matrix-rewards",
"event_types": [
"points.earned",
"points.adjusted"
],
"description": "Our CRM",
"status": "enabled",
"created_at": "2026-09-16T10:00:00.000Z",
"disabled_at": null,
"disabled_reason": null,
"consecutive_failures": 0,
"failing_since": null,
"last_success_at": null,
"last_failure_at": null,
"secret": "whsec_EXAMPLE"
}
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_webhooks (required_scope says so). forbidden_for_assistant: a key made for an AI assistant can never manage webhooks. test_mode_key: nor can a test key — an endpoint sends real data out. previous_secret (creating an endpoint only): the request was signed with the key's previous secret, which still works while a rotation's grace lasts; create endpoints with the current secret. | forbidden_scope, forbidden_for_assistant, test_mode_key, previous_secret |
| 409 | webhook_limit_reached: the account already has 5 enabled endpoints (max_endpoints). Delete one first. | webhook_limit_reached |
| 413 | payload_too_large: the body is over 4,096 bytes. | payload_too_large |
| 429 | rate_limited: too many requests for this key; bucket says which allowance. Retry-After says how long to wait. | rate_limited |
| 503 | change_not_recorded: the audit log could not be written, so nothing was done — no endpoint made or deleted, no test sent. Try again. | change_not_recorded |
Stop sending to an endpoint
DELETE /webhooks/{endpoint_id} — the full address is https://app.matrixrewards.co.uk/api/2026-10/webhooks/{endpoint_id}.
Needs: the write_webhooks permission (in the app: “Send every change to members’ points, by member key, to a web address the key chooses (webhooks). Not for an assistant key”).
Nothing more is sent to it, and events waiting to be sent are abandoned. The endpoint and its audit entry (webhook.delete) change together.
Parameters
| Name | In | Required | Type | What it is |
|---|---|---|---|---|
endpoint_id | path | yes | string, matching ^we_[A-Za-z0-9_-]{16,32}$ | A webhook endpoint on this account (we_…). Another account's id answers 404 not_found, exactly as one that never existed. |
The answer
Deleted.
| Field | Type | Always there | What it is |
|---|---|---|---|
object | one of webhook_endpoint_deleted | yes | — |
id | string | yes | — |
deleted | one of true | yes | — |
abandoned_messages | integer, at least 0 | yes | Events that were waiting to be sent to it, and now never will be. |
Headers on the answer: Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset. What each header means.
Example
export ENDPOINT='<id of an endpoint from GET /webhooks>'
curl -X DELETE "https://app.matrixrewards.co.uk/api/2026-10/webhooks/$ENDPOINT" \
-H "Authorization: Bearer $MATRIX_API_KEY"
The answer:
{
"object": "webhook_endpoint_deleted",
"id": "we_Q3mN8xR2vT6pL9wK4sJ7hF1d",
"deleted": true,
"abandoned_messages": 0
}
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 hold write_webhooks (required_scope says so). forbidden_for_assistant: a key made for an AI assistant can never manage webhooks. test_mode_key: nor can a test key — an endpoint sends real data out. previous_secret (creating an endpoint only): the request was signed with the key's previous secret, which still works while a rotation's grace lasts; create endpoints with the current secret. | forbidden_scope, forbidden_for_assistant, test_mode_key, previous_secret |
| 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 |
| 503 | change_not_recorded: the audit log could not be written, so nothing was done — no endpoint made or deleted, no test sent. Try again. | change_not_recorded |
Send one signed test event now
POST /webhooks/{endpoint_id}/test — the full address is https://app.matrixrewards.co.uk/api/2026-10/webhooks/{endpoint_id}/test.
Needs: the write_webhooks permission (in the app: “Send every change to members’ points, by member key, to a web address the key chooses (webhooks). Not for an assistant key”).
A webhook.test event, signed with the endpoint's secret and sent through the same checks as every delivery, answered with what happened. It is not stored or retried, and it never counts towards disabling the endpoint. Recorded in the audit log (webhook.test) before it is sent. Works on a disabled endpoint too, so a fixed receiver can be checked.
Parameters
| Name | In | Required | Type | What it is |
|---|---|---|---|---|
endpoint_id | path | yes | string, matching ^we_[A-Za-z0-9_-]{16,32}$ | A webhook endpoint on this account (we_…). Another account's id answers 404 not_found, exactly as one that never existed. |
The answer
What the receiver did. delivered is false for every failure; the request itself succeeded.
| Field | Type | Always there | What it is |
|---|---|---|---|
object | one of webhook_test | yes | — |
id | string | yes | The webhook-id the test was sent with. |
delivered | boolean | yes | True when the receiver answered 2xx. |
status | integer, or null | yes | The receiver's HTTP status, when it answered. |
error | string, or null | yes | Why it was not delivered. redirect: a 3xx, which is never followed. timeout: no answer within 15 seconds. One of unsafe_url, blocked_address, dns_failed, connection_failed, tls_failed, timeout, redirect, http_status, secret_unavailable. |
duration_ms | integer, at least 0 | yes | — |
Headers on the answer: Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset. What each header means.
Example
export ENDPOINT='<id of an endpoint from GET /webhooks>'
curl -X POST "https://app.matrixrewards.co.uk/api/2026-10/webhooks/$ENDPOINT/test" \
-H "Authorization: Bearer $MATRIX_API_KEY"
The answer:
{
"object": "webhook_test",
"id": "evt_test_4c0a9e7d21b3f58e6a0d9c12",
"delivered": true,
"status": 200,
"error": null,
"duration_ms": 212
}
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 hold write_webhooks (required_scope says so). forbidden_for_assistant: a key made for an AI assistant can never manage webhooks. test_mode_key: nor can a test key — an endpoint sends real data out. previous_secret (creating an endpoint only): the request was signed with the key's previous secret, which still works while a rotation's grace lasts; create endpoints with the current secret. | forbidden_scope, forbidden_for_assistant, test_mode_key, previous_secret |
| 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 |
| 503 | change_not_recorded: the audit log could not be written, so nothing was done — no endpoint made or deleted, no test sent. Try again. | change_not_recorded |
Events we send you
How each event is signed, sent, retried and ordered is at the start of this page. These are the events, and what each one holds.
Signing headers
| Header | What it is |
|---|---|
webhook-id | evt_<ledger entry id>: the same on every attempt and at every endpoint. |
webhook-timestamp | Unix seconds when this attempt was sent. |
webhook-signature | v1,<base64 HMAC-SHA256> of <webhook-id>.<webhook-timestamp>.<body>. |
points.earned — Points were earned: an order, a welcome or birthday bonus, a referral
What it holds
| Field | Type | Always there | What it is |
|---|---|---|---|
type | string | yes | What kind of points movement (WebhookEventType). One of points.earned, points.adjusted, points.redeemed, points.returned, points.clawed_back, points.expired, points.changed. |
timestamp | string | yes | When the platform recorded the movement. |
data | object | yes | — |
data.ledger_entry_id | integer | yes | The id of the entry in GET /ledger. |
data.store_key | string | yes | — |
data.member_key | string | yes | — |
data.delta_points | integer | yes | — |
data.rule_id | string | yes | — |
data.source_type | string | yes | The ledger's own kind of movement, as GET /ledger gives it. |
data.created_at | string | yes | The movement's own time — for an order, the order's. |
data.adjustment_id | string, or null | no | points.adjusted only: the adjustment's id — for a change made through this API, its Idempotency-Key. |
An example:
{
"type": "points.earned",
"timestamp": "2026-09-16T10:00:05.000Z",
"data": {
"ledger_entry_id": 4,
"store_key": "example-store",
"member_key": "mx_3f6c2a9e-8b41-4d7a-9e05-c1b7d24f6a18",
"delta_points": 120,
"rule_id": "base_earn",
"source_type": "order",
"created_at": "2026-09-16T10:00:00Z"
}
}
Answer 2XX: Received. Anything else, a redirect, or no answer within 15 seconds is retried.
points.adjusted — The merchant changed a member's points: in the app, on the web page, or through this API
It holds the same fields as points.earned (WebhookEvent).
An example:
{
"type": "points.adjusted",
"timestamp": "2026-09-16T10:00:05.000Z",
"data": {
"ledger_entry_id": 2,
"store_key": "example-store",
"member_key": "mx_3f6c2a9e-8b41-4d7a-9e05-c1b7d24f6a18",
"delta_points": 50,
"rule_id": "admin_adjustment",
"source_type": "admin_adjustment",
"created_at": "2026-09-16T10:00:00.000Z",
"adjustment_id": "goodwill-2026-09-16-0001"
}
}
Answer 2XX: Received. Anything else, a redirect, or no answer within 15 seconds is retried.
points.redeemed — Points were spent on a reward, or taken back because a cancelled code had been spent
It holds the same fields as points.earned (WebhookEvent).
An example:
{
"type": "points.redeemed",
"timestamp": "2026-09-16T10:00:05.000Z",
"data": {
"ledger_entry_id": 5,
"store_key": "example-store",
"member_key": "mx_3f6c2a9e-8b41-4d7a-9e05-c1b7d24f6a18",
"delta_points": -500,
"rule_id": "redeem",
"source_type": "redeem",
"created_at": "2026-09-16T10:00:00Z"
}
}
Answer 2XX: Received. Anything else, a redirect, or no answer within 15 seconds is retried.
points.returned — Points came back: a reward was cancelled, or could not be issued
It holds the same fields as points.earned (WebhookEvent).
An example:
{
"type": "points.returned",
"timestamp": "2026-09-16T10:00:05.000Z",
"data": {
"ledger_entry_id": 3,
"store_key": "example-store",
"member_key": "mx_3f6c2a9e-8b41-4d7a-9e05-c1b7d24f6a18",
"delta_points": 500,
"rule_id": "redeem_void",
"source_type": "redeem_void",
"created_at": "2026-09-16T10:00:00.000Z"
}
}
Answer 2XX: Received. Anything else, a redirect, or no answer within 15 seconds is retried.
points.clawed_back — Points were taken back after a refund
It holds the same fields as points.earned (WebhookEvent).
An example:
{
"type": "points.clawed_back",
"timestamp": "2026-09-16T10:00:05.000Z",
"data": {
"ledger_entry_id": 6,
"store_key": "example-store",
"member_key": "mx_3f6c2a9e-8b41-4d7a-9e05-c1b7d24f6a18",
"delta_points": -40,
"rule_id": "refund",
"source_type": "refund",
"created_at": "2026-09-16T10:00:00Z"
}
}
Answer 2XX: Received. Anything else, a redirect, or no answer within 15 seconds is retried.
points.expired — Points expired
It holds the same fields as points.earned (WebhookEvent).
An example:
{
"type": "points.expired",
"timestamp": "2026-09-16T10:00:05.000Z",
"data": {
"ledger_entry_id": 7,
"store_key": "example-store",
"member_key": "mx_3f6c2a9e-8b41-4d7a-9e05-c1b7d24f6a18",
"delta_points": -80,
"rule_id": "expiry",
"source_type": "expiry",
"created_at": "2026-09-16T10:00:00Z"
}
}
Answer 2XX: Received. Anything else, a redirect, or no answer within 15 seconds is retried.
points.changed — Any other movement — today, points carried over when a shopper's data was erased at another store of the account
It holds the same fields as points.earned (WebhookEvent).
An example:
{
"type": "points.changed",
"timestamp": "2026-09-16T10:00:05.000Z",
"data": {
"ledger_entry_id": 8,
"store_key": "example-store",
"member_key": "mx_3f6c2a9e-8b41-4d7a-9e05-c1b7d24f6a18",
"delta_points": -20,
"rule_id": "erasure_carry",
"source_type": "erasure_carry",
"created_at": "2026-09-16T10:00:00Z"
}
}
Answer 2XX: Received. Anything else, a redirect, or no answer within 15 seconds is retried.
programme.updated — The programme changed
Each event is POSTed as JSON to every enabled endpoint subscribed to its type, signed the Standard Webhooks way: webhook-id (the same on every retry, and the thing to de-duplicate on), webhook-timestamp (unix seconds of this attempt; reject one more than 5 minutes from your clock), and webhook-signature: v1,<base64 HMAC-SHA256> over <webhook-id>.<webhook-timestamp>.<body>, keyed by the base64 after whsec_ in the endpoint's secret, decoded to bytes. Verify the raw body, before parsing it. Answer 2xx within 15 seconds; anything else, a redirect, or no answer is a failure, and the event is sent again after 5 seconds, 5 minutes, 30 minutes, then 2, 5, 10, 14, 20 and 24 hours (each ±10%) — ten attempts over about 75 hours — with the same id and the same bytes. Events can arrive out of order: timestamp orders them. De-duplicate on webhook-id, never on data.version, which an expiry or look change leaves as it was. An endpoint that has failed 10 deliveries in a row AND been failing for at least 24 hours is disabled: nothing more is sent to it, what was waiting is abandoned, webhook.disabled is written to the audit log, and GET /webhooks shows status: disabled. Delete it and create it again once the receiver works. An endpoint receives the events recorded after it was created.
Its signing headers differ from the table above in these:
| Header | What it is |
|---|---|
webhook-id | evt_prog_<audit id>: the same on every attempt and at every endpoint. |
What it holds
| Field | Type | Always there | What it is |
|---|---|---|---|
type | always programme.updated | yes | — |
timestamp | string | yes | When the change was made. |
data | object | yes | — |
data.store_key | string | yes | — |
data.version | string, matching ^[0-9a-f]{32}$, or null | yes | The programme's settings version after the change: what a programme read answers, and what a change sends as if_version. A change to expiry or the look alone leaves it as it was. |
data.changed | list of string | yes | The settings that changed, in the engine's names (rules, rewards, redeemPointsPerMajorUnit, referral, spendAnyAmount, expiry, appearance, …). |
data.changed_by | one of shopify_admin, api_key | yes | Where the change was made: a screen of the app in the Shopify admin, or an API key (a Claude connection included). |
data.audit_id | string | yes | The change's entry in GET /audit-log. |
An example:
{
"type": "programme.updated",
"timestamp": "2026-09-16T10:00:05.000Z",
"data": {
"store_key": "example-store",
"version": "3f1c9a2b7d4e5f60718293a4b5c6d7e8",
"changed": [
"rewards"
],
"changed_by": "api_key",
"audit_id": "5b2f1c7e-8d4a-4e3b-9f6a-1c2d3e4f5a6b"
}
}
Answer 2XX: Received. Anything else, a redirect, or no answer within 15 seconds is retried.
webhook.test — A test, sent only by POST /webhooks/{endpoint_id}/test
Signed like every event; sent once, never retried, never counted towards disabling.
Its signing headers differ from the table above in these:
| Header | What it is |
|---|---|
webhook-id | evt_test_ and random hex: a new id for every test, never a ledger entry's. |
What it holds
| Field | Type | Always there | What it is |
|---|---|---|---|
type | one of webhook.test | yes | — |
timestamp | string | yes | — |
data | object | yes | — |
data.endpoint_id | string | yes | — |
An example:
{
"type": "webhook.test",
"timestamp": "2026-09-16T10:00:00.000Z",
"data": {
"endpoint_id": "we_Q3mN8xR2vT6pL9wK4sJ7hF1d"
}
}
Answer 2XX: Received. A test is sent once and is not retried: any other answer, a redirect, or no answer within 15 seconds is reported by POST /webhooks/{endpoint_id}/test as not delivered, and never counts towards disabling the endpoint.