# 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](https://matrixrewards.co.uk/docs/api/2026-10.md). Every address below starts with the base address:

```text
https://app.matrixrewards.co.uk/api/2026-10
```

## The webhook endpoints on this account, and why any stopped {#listWebhookEndpoints}

`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](https://matrixrewards.co.uk/docs/api/2026-10/webhooks.md#events) | 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](https://matrixrewards.co.uk/docs/api/2026-10.md#headers).

### Example

```bash
curl "https://app.matrixrewards.co.uk/api/2026-10/webhooks" \
  -H "Authorization: Bearer $MATRIX_API_KEY"
```

The answer:

```json
{
  "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](https://matrixrewards.co.uk/docs/errors/unauthenticated.md) |
| 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](https://matrixrewards.co.uk/docs/errors/forbidden_scope.md), [forbidden_for_assistant](https://matrixrewards.co.uk/docs/errors/forbidden_for_assistant.md), [test_mode_key](https://matrixrewards.co.uk/docs/errors/test_mode_key.md), [previous_secret](https://matrixrewards.co.uk/docs/errors/previous_secret.md) |
| 429 | `rate_limited`: too many requests for this key; `bucket` says which allowance. `Retry-After` says how long to wait. | [rate_limited](https://matrixrewards.co.uk/docs/errors/rate_limited.md) |
| 503 | `access_not_recorded`: the read could not be written to the account's audit log, so no data was returned. | [access_not_recorded](https://matrixrewards.co.uk/docs/errors/access_not_recorded.md) |

## Send points movements to an https endpoint {#createWebhookEndpoint}

`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](https://matrixrewards.co.uk/docs/api/2026-10/webhooks.md#events), 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](https://matrixrewards.co.uk/docs/api/2026-10/webhooks.md#events) | 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](https://matrixrewards.co.uk/docs/api/2026-10.md#headers).

### Example

```bash
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:

```json
{
  "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](https://matrixrewards.co.uk/docs/errors/invalid_request.md) |
| 401 | `unauthenticated`: no usable API key was presented. Malformed, unknown, revoked and wrong-secret are one answer. | [unauthenticated](https://matrixrewards.co.uk/docs/errors/unauthenticated.md) |
| 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](https://matrixrewards.co.uk/docs/errors/forbidden_scope.md), [forbidden_for_assistant](https://matrixrewards.co.uk/docs/errors/forbidden_for_assistant.md), [test_mode_key](https://matrixrewards.co.uk/docs/errors/test_mode_key.md), [previous_secret](https://matrixrewards.co.uk/docs/errors/previous_secret.md) |
| 409 | `webhook_limit_reached`: the account already has 5 enabled endpoints (`max_endpoints`). Delete one first. | [webhook_limit_reached](https://matrixrewards.co.uk/docs/errors/webhook_limit_reached.md) |
| 413 | `payload_too_large`: the body is over 4,096 bytes. | [payload_too_large](https://matrixrewards.co.uk/docs/errors/payload_too_large.md) |
| 429 | `rate_limited`: too many requests for this key; `bucket` says which allowance. `Retry-After` says how long to wait. | [rate_limited](https://matrixrewards.co.uk/docs/errors/rate_limited.md) |
| 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](https://matrixrewards.co.uk/docs/errors/change_not_recorded.md) |

## Stop sending to an endpoint {#deleteWebhookEndpoint}

`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](https://matrixrewards.co.uk/docs/api/2026-10.md#headers).

### Example

```bash
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:

```json
{
  "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](https://matrixrewards.co.uk/docs/errors/unauthenticated.md) |
| 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](https://matrixrewards.co.uk/docs/errors/forbidden_scope.md), [forbidden_for_assistant](https://matrixrewards.co.uk/docs/errors/forbidden_for_assistant.md), [test_mode_key](https://matrixrewards.co.uk/docs/errors/test_mode_key.md), [previous_secret](https://matrixrewards.co.uk/docs/errors/previous_secret.md) |
| 404 | `not_found`: no such resource on this account — indistinguishable from one that does not exist at all. | [not_found](https://matrixrewards.co.uk/docs/errors/not_found.md) |
| 429 | `rate_limited`: too many requests for this key; `bucket` says which allowance. `Retry-After` says how long to wait. | [rate_limited](https://matrixrewards.co.uk/docs/errors/rate_limited.md) |
| 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](https://matrixrewards.co.uk/docs/errors/change_not_recorded.md) |

## Send one signed test event now {#testWebhookEndpoint}

`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](https://matrixrewards.co.uk/docs/api/2026-10.md#headers).

### Example

```bash
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:

```json
{
  "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](https://matrixrewards.co.uk/docs/errors/unauthenticated.md) |
| 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](https://matrixrewards.co.uk/docs/errors/forbidden_scope.md), [forbidden_for_assistant](https://matrixrewards.co.uk/docs/errors/forbidden_for_assistant.md), [test_mode_key](https://matrixrewards.co.uk/docs/errors/test_mode_key.md), [previous_secret](https://matrixrewards.co.uk/docs/errors/previous_secret.md) |
| 404 | `not_found`: no such resource on this account — indistinguishable from one that does not exist at all. | [not_found](https://matrixrewards.co.uk/docs/errors/not_found.md) |
| 429 | `rate_limited`: too many requests for this key; `bucket` says which allowance. `Retry-After` says how long to wait. | [rate_limited](https://matrixrewards.co.uk/docs/errors/rate_limited.md) |
| 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](https://matrixrewards.co.uk/docs/errors/change_not_recorded.md) |

## Events we send you {#events}

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 {#event-points-earned}

### 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:

```json
{
  "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 {#event-points-adjusted}

It holds the same fields as `points.earned` (`WebhookEvent`).

An example:

```json
{
  "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 {#event-points-redeemed}

It holds the same fields as `points.earned` (`WebhookEvent`).

An example:

```json
{
  "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 {#event-points-returned}

It holds the same fields as `points.earned` (`WebhookEvent`).

An example:

```json
{
  "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 {#event-points-clawed-back}

It holds the same fields as `points.earned` (`WebhookEvent`).

An example:

```json
{
  "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 {#event-points-expired}

It holds the same fields as `points.earned` (`WebhookEvent`).

An example:

```json
{
  "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 {#event-points-changed}

It holds the same fields as `points.earned` (`WebhookEvent`).

An example:

```json
{
  "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 {#event-programme-updated}

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:

```json
{
  "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 {#event-webhook-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:

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

---

Page: https://matrixrewards.co.uk/docs/api/2026-10/webhooks
Contract: 2026-10 sha256:35af153960028890fea5738799f5a71e535e1eea4dd3f6b8d5f50b37f02363da
