For a loyalty programme partner: read a membership, confirm a policyholder's enrolment, suspend or cancel it, and send the points and tiers they earn.
These need a server-only application, with write for changes and read or write to read a membership. The
guide Loyalty partners walks through it.
Send points and tiers
POST
https://subcontext.com/api/v1/programme_memberships/{external_ref}/events
Try it
Token scopes:
write
Sends what a member has earned: points awarded or adjusted, and the
tier your programme says they're on. Members earn once their
enrolment is confirmed. Until then this answers 409. Either all of a batch is recorded or none of it is, so you can send a failed delivery again. Each event needs its own idempotency_key, and one already recorded is skipped (replayed is true).
Server-only applications, with the write scope.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| external_ref required | path | string | Your own reference for the member ( |
Request body application/json
| Field | Type | Description |
|---|---|---|
| events required | array of object | |
| events[].idempotency_key required | string |
Unique to this event in your system. |
| events[].entry_type required | string |
Points earned, a correction to points (can be negative), or the tier the member is on. One of:"points_awarded", "points_adjusted", "tier_asserted"
|
| events[].points | integer |
For points events, how many. |
| events[].tier_key | string |
For |
| events[].occurred_at | string (date-time) |
When it happened (ISO 8601). Now if left out. |
| events[].payload | object |
Anything else to keep with the event, such as the activity that earned the points. |
{
"events": [
{
"idempotency_key": "vit-evt-55120",
"entry_type": "points_awarded",
"points": 500,
"occurred_at": "2026-10-11T07:30:00Z",
"payload": {
"activity": "weekly_steps"
}
},
{
"idempotency_key": "vit-evt-55121",
"entry_type": "tier_asserted",
"tier_key": "silver"
}
]
}
Responses
200 The batch is recorded. The membership shows its new points and tier.
| Field | Type | Description |
|---|---|---|
| data required | object | |
| data.events required | array of object |
One for each event, in order. |
| data.events[].id required | string (uuid) | |
| data.events[].entry_type required | string |
One of: "points_awarded", "points_adjusted", "tier_asserted"
|
| data.events[].points required | integer or null | |
| data.events[].tier_key required | string or null | |
| data.events[].replayed required | boolean |
|
| data.membership required | Membership |
A policyholder's membership of your loyalty programme. |
| data.membership.id required | string (uuid) |
Subcontext's id for the membership. |
| data.membership.programme_slug required | string | |
| data.membership.programme_name required | string | |
| data.membership.policy_number required | string | |
| data.membership.status required | string |
One of: "pending", "active", "suspended", "cancelled"
|
| data.membership.external_member_ref required | string or null |
Your reference for the member, once confirmed. |
| data.membership.current_tier_key required | string or null | |
| data.membership.cycle_points required | integer |
Points earned this cycle. |
| data.membership.cycle_started_on required | string or null (date) | |
| data.membership.cycle_ends_on required | string or null (date) |
{
"data": {
"events": [
{
"id": "0199a3c2-8700-7b88-8e09-8dfe0f102132",
"entry_type": "points_awarded",
"points": 500,
"tier_key": null,
"replayed": false
},
{
"id": "0199a3c2-8701-7c99-9f1a-9e0f10213243",
"entry_type": "tier_asserted",
"points": 0,
"tier_key": "silver",
"replayed": false
}
],
"membership": {
"id": "0199a3c2-8800-7daa-8a2b-af1021324354",
"programme_slug": "rewards",
"programme_name": "Rewards",
"policy_number": "POL-AB-P001-000042",
"status": "active",
"external_member_ref": "VIT-1001",
"current_tier_key": "silver",
"cycle_points": 2400,
"cycle_started_on": "2026-01-01",
"cycle_ends_on": "2026-12-31"
}
}
}
401 No token was sent, or it isn't valid because it has expired, was revoked (for example because the application's secret was rotated), or was cut short. Get a new token and try once more.
| Field | Type | Description |
|---|---|---|
| error required | object | |
| error.code required | string |
A short code for the kind of error, such as |
| error.message required | string |
What went wrong and what to do about it, in plain English. |
| error.fields | object |
When the error is about particular inputs, which ones and why,
by name: a nested one with a dot ( |
{
"error": {
"code": "invalid_token",
"message": "The access token has expired. Ask for a new one."
}
}
403 The token can't be used here. It's missing a scope, it was used from the wrong place (a server-only application's token in a browser, or a browser application's token from a server, another website or a request only server-only applications may make), or it wasn't asked for with `resource=https://subcontext.com/api/v1`.
| Field | Type | Description |
|---|---|---|
| error required | object | |
| error.code required | string |
A short code for the kind of error, such as |
| error.message required | string |
What went wrong and what to do about it, in plain English. |
| error.fields | object |
When the error is about particular inputs, which ones and why,
by name: a nested one with a dot ( |
{
"error": {
"code": "forbidden",
"message": "origin 'https://www.example.com' is not allowlisted for this OAuth application"
}
}
404 There's no such record in your organisation.
| Field | Type | Description |
|---|---|---|
| error required | object | |
| error.code required | string |
A short code for the kind of error, such as |
| error.message required | string |
What went wrong and what to do about it, in plain English. |
| error.fields | object |
When the error is about particular inputs, which ones and why,
by name: a nested one with a dot ( |
{
"error": {
"code": "not_found",
"message": "Resource not found"
}
}
409 Nothing was recorded: - `membership_pending`: the enrolment isn't confirmed yet. Confirm it with [Update a membership](/developers/api/reference/loyalty#updateMembership) first. - `membership_cancelled`: the membership is cancelled and earns nothing more.
| Field | Type | Description |
|---|---|---|
| error required | object | |
| error.code required | string |
A short code for the kind of error, such as |
| error.message required | string |
What went wrong and what to do about it, in plain English. |
| error.fields | object |
When the error is about particular inputs, which ones and why,
by name: a nested one with a dot ( |
{
"error": {
"code": "membership_cancelled",
"message": "this membership is cancelled and can't be changed"
}
}
422 `validation_failed`: one of the events isn't valid (an unknown `entry_type`, a missing `idempotency_key` or tier, a time that isn't ISO 8601), or `events` is missing. `fields` names it, as `events[1].entry_type`. Nothing in the batch was recorded.
| Field | Type | Description |
|---|---|---|
| error required | object | |
| error.code required | string |
A short code for the kind of error, such as |
| error.message required | string |
What went wrong and what to do about it, in plain English. |
| error.fields | object |
When the error is about particular inputs, which ones and why,
by name: a nested one with a dot ( |
{
"error": {
"code": "validation_failed",
"message": "events[1].idempotency_key is required on every event",
"fields": {
"events[1].idempotency_key": "is required on every event"
}
}
}
Get a membership
GET
https://subcontext.com/api/v1/programme_memberships/{external_ref}
Try it
Token scopes:
read or write
A membership as it is now: its status, your reference, its tier and the points earned this cycle. Before you've confirmed the enrolment, find it by Subcontext's membership id. After that, find it by your reference.
Server-only applications.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| external_ref required | path | string | Your own reference for the member ( |
Responses
200 The membership.
| Field | Type | Description |
|---|---|---|
| data required | Membership |
A policyholder's membership of your loyalty programme. |
| data.id required | string (uuid) |
Subcontext's id for the membership. |
| data.programme_slug required | string | |
| data.programme_name required | string | |
| data.policy_number required | string | |
| data.status required | string |
One of: "pending", "active", "suspended", "cancelled"
|
| data.external_member_ref required | string or null |
Your reference for the member, once confirmed. |
| data.current_tier_key required | string or null | |
| data.cycle_points required | integer |
Points earned this cycle. |
| data.cycle_started_on required | string or null (date) | |
| data.cycle_ends_on required | string or null (date) |
{
"data": {
"id": "0199a3c2-8800-7daa-8a2b-af1021324354",
"programme_slug": "rewards",
"programme_name": "Rewards",
"policy_number": "POL-AB-P001-000042",
"status": "pending",
"external_member_ref": null,
"current_tier_key": "bronze",
"cycle_points": 0,
"cycle_started_on": "2026-10-12",
"cycle_ends_on": "2027-10-12"
}
}
401 No token was sent, or it isn't valid because it has expired, was revoked (for example because the application's secret was rotated), or was cut short. Get a new token and try once more.
| Field | Type | Description |
|---|---|---|
| error required | object | |
| error.code required | string |
A short code for the kind of error, such as |
| error.message required | string |
What went wrong and what to do about it, in plain English. |
| error.fields | object |
When the error is about particular inputs, which ones and why,
by name: a nested one with a dot ( |
{
"error": {
"code": "invalid_token",
"message": "The access token has expired. Ask for a new one."
}
}
403 The token can't be used here. It's missing a scope, it was used from the wrong place (a server-only application's token in a browser, or a browser application's token from a server, another website or a request only server-only applications may make), or it wasn't asked for with `resource=https://subcontext.com/api/v1`.
| Field | Type | Description |
|---|---|---|
| error required | object | |
| error.code required | string |
A short code for the kind of error, such as |
| error.message required | string |
What went wrong and what to do about it, in plain English. |
| error.fields | object |
When the error is about particular inputs, which ones and why,
by name: a nested one with a dot ( |
{
"error": {
"code": "forbidden",
"message": "origin 'https://www.example.com' is not allowlisted for this OAuth application"
}
}
404 There's no such record in your organisation.
| Field | Type | Description |
|---|---|---|
| error required | object | |
| error.code required | string |
A short code for the kind of error, such as |
| error.message required | string |
What went wrong and what to do about it, in plain English. |
| error.fields | object |
When the error is about particular inputs, which ones and why,
by name: a nested one with a dot ( |
{
"error": {
"code": "not_found",
"message": "Resource not found"
}
}
Update a membership (PUT)
PUT
https://subcontext.com/api/v1/programme_memberships/{external_ref}
Try it
Token scopes:
write
The same as Update a membership,
for systems that send PUT. It works as PATCH does. Only the fields you send change, and it doesn't replace the whole membership.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| external_ref required | path | string | Your own reference for the member ( |
Request body application/json
| Field | Type | Description |
|---|---|---|
| status | string |
The new status. One of:"active", "suspended", "cancelled"
|
| external_member_ref | string |
Your reference for the member. Required to confirm a pending enrolment. |
{
"status": "suspended"
}
Responses
200 The membership, as it is now.
| Field | Type | Description |
|---|---|---|
| data required | Membership |
A policyholder's membership of your loyalty programme. |
| data.id required | string (uuid) |
Subcontext's id for the membership. |
| data.programme_slug required | string | |
| data.programme_name required | string | |
| data.policy_number required | string | |
| data.status required | string |
One of: "pending", "active", "suspended", "cancelled"
|
| data.external_member_ref required | string or null |
Your reference for the member, once confirmed. |
| data.current_tier_key required | string or null | |
| data.cycle_points required | integer |
Points earned this cycle. |
| data.cycle_started_on required | string or null (date) | |
| data.cycle_ends_on required | string or null (date) |
{
"data": {
"id": "0199a3c2-8800-7daa-8a2b-af1021324354",
"programme_slug": "rewards",
"programme_name": "Rewards",
"policy_number": "POL-AB-P001-000042",
"status": "active",
"external_member_ref": "VIT-1001",
"current_tier_key": "bronze",
"cycle_points": 0,
"cycle_started_on": "2026-10-12",
"cycle_ends_on": "2027-10-12"
}
}
401 No token was sent, or it isn't valid because it has expired, was revoked (for example because the application's secret was rotated), or was cut short. Get a new token and try once more.
| Field | Type | Description |
|---|---|---|
| error required | object | |
| error.code required | string |
A short code for the kind of error, such as |
| error.message required | string |
What went wrong and what to do about it, in plain English. |
| error.fields | object |
When the error is about particular inputs, which ones and why,
by name: a nested one with a dot ( |
{
"error": {
"code": "invalid_token",
"message": "The access token has expired. Ask for a new one."
}
}
403 The token can't be used here. It's missing a scope, it was used from the wrong place (a server-only application's token in a browser, or a browser application's token from a server, another website or a request only server-only applications may make), or it wasn't asked for with `resource=https://subcontext.com/api/v1`.
| Field | Type | Description |
|---|---|---|
| error required | object | |
| error.code required | string |
A short code for the kind of error, such as |
| error.message required | string |
What went wrong and what to do about it, in plain English. |
| error.fields | object |
When the error is about particular inputs, which ones and why,
by name: a nested one with a dot ( |
{
"error": {
"code": "forbidden",
"message": "origin 'https://www.example.com' is not allowlisted for this OAuth application"
}
}
404 There's no such record in your organisation.
| Field | Type | Description |
|---|---|---|
| error required | object | |
| error.code required | string |
A short code for the kind of error, such as |
| error.message required | string |
What went wrong and what to do about it, in plain English. |
| error.fields | object |
When the error is about particular inputs, which ones and why,
by name: a nested one with a dot ( |
{
"error": {
"code": "not_found",
"message": "Resource not found"
}
}
409 The membership's state won't allow it: - `membership_cancelled`: it's cancelled, and can't be changed. - `invalid_transition`: not from the status it's in (for example suspending a `pending` one). - `external_member_ref_mismatch`: it has a confirmed reference, and this one is different.
| Field | Type | Description |
|---|---|---|
| error required | object | |
| error.code required | string |
A short code for the kind of error, such as |
| error.message required | string |
What went wrong and what to do about it, in plain English. |
| error.fields | object |
When the error is about particular inputs, which ones and why,
by name: a nested one with a dot ( |
{
"error": {
"code": "invalid_transition",
"message": "a pending membership can't become suspended"
}
}
422 `validation_failed`: the request can't be used (the message says why): an unknown `status`, nothing to change, or confirming without `external_member_ref`.
| Field | Type | Description |
|---|---|---|
| error required | object | |
| error.code required | string |
A short code for the kind of error, such as |
| error.message required | string |
What went wrong and what to do about it, in plain English. |
| error.fields | object |
When the error is about particular inputs, which ones and why,
by name: a nested one with a dot ( |
{
"error": {
"code": "validation_failed",
"message": "external_member_ref is required to confirm the enrolment",
"fields": {
"status": "must be active, suspended or cancelled, with external_member_ref to confirm an enrolment"
}
}
}
Update a membership
PATCH
https://subcontext.com/api/v1/programme_memberships/{external_ref}
Try it
Token scopes:
write
Confirms an enrolment or changes a membership's status.
When a policyholder joins, the membership starts pending, and you
find it by Subcontext's membership id. Confirm it by sending your
own member reference (external_member_ref) and status: active.
From then on, use your reference in the address.
Later, send status to suspend (suspended), reinstate (active)
or end (cancelled) it. A cancelled membership can't be changed.
Sending external_member_ref on its own, with no status, records
it as the member's reference while the membership is pending. Once a reference is confirmed it's fixed. Sending the same one again is harmless, and a different one is refused
(409 external_member_ref_mismatch), as when confirming. Only your firm's own team can correct a confirmed reference, in the dashboard.
Server-only applications, with the write scope.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| external_ref required | path | string | Your own reference for the member ( |
Request body application/json
| Field | Type | Description |
|---|---|---|
| status | string |
The new status. One of:"active", "suspended", "cancelled"
|
| external_member_ref | string |
Your reference for the member. Required to confirm a pending enrolment. |
{
"status": "active",
"external_member_ref": "VIT-1001"
}
Responses
200 The membership, as it is now.
| Field | Type | Description |
|---|---|---|
| data required | Membership |
A policyholder's membership of your loyalty programme. |
| data.id required | string (uuid) |
Subcontext's id for the membership. |
| data.programme_slug required | string | |
| data.programme_name required | string | |
| data.policy_number required | string | |
| data.status required | string |
One of: "pending", "active", "suspended", "cancelled"
|
| data.external_member_ref required | string or null |
Your reference for the member, once confirmed. |
| data.current_tier_key required | string or null | |
| data.cycle_points required | integer |
Points earned this cycle. |
| data.cycle_started_on required | string or null (date) | |
| data.cycle_ends_on required | string or null (date) |
{
"data": {
"id": "0199a3c2-8800-7daa-8a2b-af1021324354",
"programme_slug": "rewards",
"programme_name": "Rewards",
"policy_number": "POL-AB-P001-000042",
"status": "active",
"external_member_ref": "VIT-1001",
"current_tier_key": "bronze",
"cycle_points": 0,
"cycle_started_on": "2026-10-12",
"cycle_ends_on": "2027-10-12"
}
}
401 No token was sent, or it isn't valid because it has expired, was revoked (for example because the application's secret was rotated), or was cut short. Get a new token and try once more.
| Field | Type | Description |
|---|---|---|
| error required | object | |
| error.code required | string |
A short code for the kind of error, such as |
| error.message required | string |
What went wrong and what to do about it, in plain English. |
| error.fields | object |
When the error is about particular inputs, which ones and why,
by name: a nested one with a dot ( |
{
"error": {
"code": "invalid_token",
"message": "The access token has expired. Ask for a new one."
}
}
403 The token can't be used here. It's missing a scope, it was used from the wrong place (a server-only application's token in a browser, or a browser application's token from a server, another website or a request only server-only applications may make), or it wasn't asked for with `resource=https://subcontext.com/api/v1`.
| Field | Type | Description |
|---|---|---|
| error required | object | |
| error.code required | string |
A short code for the kind of error, such as |
| error.message required | string |
What went wrong and what to do about it, in plain English. |
| error.fields | object |
When the error is about particular inputs, which ones and why,
by name: a nested one with a dot ( |
{
"error": {
"code": "forbidden",
"message": "origin 'https://www.example.com' is not allowlisted for this OAuth application"
}
}
404 There's no such record in your organisation.
| Field | Type | Description |
|---|---|---|
| error required | object | |
| error.code required | string |
A short code for the kind of error, such as |
| error.message required | string |
What went wrong and what to do about it, in plain English. |
| error.fields | object |
When the error is about particular inputs, which ones and why,
by name: a nested one with a dot ( |
{
"error": {
"code": "not_found",
"message": "Resource not found"
}
}
409 The membership's state won't allow it: - `membership_cancelled`: it's cancelled, and can't be changed. - `invalid_transition`: not from the status it's in (for example suspending a `pending` one). - `external_member_ref_mismatch`: it has a confirmed reference, and this one is different.
| Field | Type | Description |
|---|---|---|
| error required | object | |
| error.code required | string |
A short code for the kind of error, such as |
| error.message required | string |
What went wrong and what to do about it, in plain English. |
| error.fields | object |
When the error is about particular inputs, which ones and why,
by name: a nested one with a dot ( |
{
"error": {
"code": "invalid_transition",
"message": "a pending membership can't become suspended"
}
}
422 `validation_failed`: the request can't be used (the message says why): an unknown `status`, nothing to change, or confirming without `external_member_ref`.
| Field | Type | Description |
|---|---|---|
| error required | object | |
| error.code required | string |
A short code for the kind of error, such as |
| error.message required | string |
What went wrong and what to do about it, in plain English. |
| error.fields | object |
When the error is about particular inputs, which ones and why,
by name: a nested one with a dot ( |
{
"error": {
"code": "validation_failed",
"message": "external_member_ref is required to confirm the enrolment",
"fields": {
"status": "must be active, suspended or cancelled, with external_member_ref to confirm an enrolment"
}
}
}