Subscriptions API
The Subscriptions API is the consent surface of Joryio. It manages, per contact, the opt-in/opt-out status of each messaging channel (email, SMS, WhatsApp, push, Viber), membership in subscription lists (consent categories / topics), email bounce state, and the complete audit history of consent changes.
This page covers three resources:
- Contact consent -
/subscriptions/contacts/...: read and change a single contact's channel and list consent. - Subscription lists -
/lists: create and manage the lists themselves, plus bulk membership operations. - Hosted preference page -
/subscriptions/hosted-page: author the workspace's hosted preference-center page.
All endpoints on this page are relative to the base URL: https://api-eu1.joryio.com - see API Overview.
Authentication
All requests require API key authentication (a dashboard session JWT works too):
Authorization: Bearer jry_live_your_api_key_here
Content-Type: application/json
Scopes per endpoint
| Endpoint | Scope |
|---|---|
GET /subscriptions/contacts/:userId | compliance:read |
PUT /subscriptions/contacts/:userId/channels/:channel | compliance:write |
POST /subscriptions/contacts/:userId/clear-bounce | compliance:write |
GET /subscriptions/contacts/:userId/lists | compliance:read |
POST /subscriptions/contacts/:userId/lists/:listId | compliance:write |
DELETE /subscriptions/contacts/:userId/lists/:listId | compliance:write |
GET /subscriptions/contacts/:userId/history | compliance:read |
GET /subscriptions/contacts/:userId/channels/:channel/status | compliance:read |
GET /subscriptions/contacts/:userId/email-valid | compliance:read |
POST /lists | settings:write |
GET /lists, GET /lists/:listId, GET /lists/:listId/stats | settings:read |
GET /lists/:listId/members | compliance:read |
PUT /lists/:listId, DELETE /lists/:listId, POST /lists/:listId/restore | settings:write |
POST /lists/:listId/members/bulk, DELETE /lists/:listId/members/bulk | settings:write |
GET /subscriptions/hosted-page/preference-center, POST /subscriptions/hosted-page/preview | settings:read |
PUT /subscriptions/hosted-page/preference-center | settings:write |
Core concepts
The contact id
Every /subscriptions/contacts/:userId route takes Joryio's internal contact id - the id returned by the Users API - not the external userId you send in events. It must be a 24-character hex ObjectId (or UUID); anything else is rejected with 400 Bad Request.
Subscribing at creation time
You can subscribe a contact to lists in the same call that creates them: POST /users accepts an optional subscriptions array (one-call onboarding) - see the Users API. The standalone endpoints on this page remain the way to manage consent after creation: set channel consent with PUT /subscriptions/contacts/:userId/channels/:channel and list membership with POST /subscriptions/contacts/:userId/lists/:listId. Create-time subscriptions never resurrect an existing opt-out - an explicit re-opt-in must use POST /subscriptions/contacts/:userId/lists/:listId.
Channels
Five subscription channels exist: email, sms, whatsapp, push, viber. Any other channel value returns 400 Bad Request.
Status values
| Status | Description |
|---|---|
optedIn | Explicit opt-in (e.g. confirmed double opt-in) |
subscribed | Subscribed (single opt-in / default) |
unsubscribed | Opted out |
Status values are camelCase - optedIn, not opted_in. A contact with no recorded status for a channel is treated as subscribed by the send gates ("no record" is not an opt-out).
Consent changes are audited and mirrored
Every write through this API is recorded in the contact's subscription audit log (with source, IP address, user agent, and the acting API key's / admin's identity) and retrievable via the history endpoint. Opt-outs are additionally mirrored to identifier-keyed suppression ledgers - an SMS/WhatsApp unsubscribe follows the phone number and an email unsubscribe follows the address (workspace-wide), so duplicate contacts sharing the identifier are covered too. See the Suppressions API.
Contact consent
Get contact subscriptions
Return a contact's per-channel consent state plus their list memberships.
Endpoint
GET /subscriptions/contacts/:userId
Path parameters
| Parameter | Type | Description |
|---|---|---|
userId | string | Joryio contact id |
Example request
curl -X GET https://api-eu1.joryio.com/subscriptions/contacts/665f1c2ab3d4e5f6a7b8c9d0 \
-H "Authorization: Bearer jry_live_your_api_key"
Response
{
"channels": {
"email": {
"status": "subscribed",
"optInDate": "2026-05-14T09:21:07.000Z",
"optInSource": "api",
"consentText": "Send me product updates",
"isValid": true,
"bounceType": null,
"bounceCount": 0
},
"sms": {
"status": "unsubscribed",
"optOutDate": "2026-06-02T18:40:00.000Z",
"optInSource": "preference_center"
}
},
"lists": [
{
"_id": "665f2e11aa22bb33cc44dd55",
"organizationId": "org-uuid",
"workspaceId": "ws-uuid",
"contactId": "665f1c2ab3d4e5f6a7b8c9d0",
"listId": "3f6c1a2e-9d4b-4f0a-8c7e-1b2d3e4f5a6b",
"channel": "email",
"status": "subscribed",
"subscribedAt": "2026-05-14T09:21:07.000Z",
"optInSource": "api",
"createdAt": "2026-05-14T09:21:07.000Z",
"updatedAt": "2026-05-14T09:21:07.000Z"
}
]
}
Channels the contact has no recorded state for are simply absent from channels. The email channel carries the extra bounce fields (isValid, bounceType, bounceCount, lastBounceAt). lists returns the contact's first 500 membership rows.
Returns 404 Not Found if the contact does not exist in the workspace.
Update channel subscription
Set a contact's consent status for one channel.
Endpoint
PUT /subscriptions/contacts/:userId/channels/:channel
Path parameters
| Parameter | Type | Description |
|---|---|---|
userId | string | Joryio contact id |
channel | string | email, sms, whatsapp, push, or viber |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
channel | string | Yes | Must match the URL channel (the URL value wins if they differ) |
status | string | Yes | optedIn, subscribed, or unsubscribed |
source | string | No | Where the change came from, e.g. api, preference_center (default api) |
consentText | string | No | The consent text shown at opt-in (stored for compliance) |
reason | string | No | Free-text reason, stored in the audit-log entry |
Example request
curl -X PUT https://api-eu1.joryio.com/subscriptions/contacts/665f1c2ab3d4e5f6a7b8c9d0/channels/email \
-H "Authorization: Bearer jry_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"channel": "email",
"status": "unsubscribed",
"source": "preference_center",
"reason": "User requested via support ticket"
}'
Response
The contact's full updated channels object:
{
"email": {
"status": "unsubscribed",
"optOutDate": "2026-07-12T10:15:00.000Z",
"optInSource": "preference_center",
"isValid": true,
"bounceType": null,
"bounceCount": 0
},
"sms": {
"status": "subscribed",
"optInDate": "2026-05-14T09:21:07.000Z"
}
}
Behavior notes
- An opt-in (
optedIn/subscribed) on the email channel clears transient soft-bounce state, but never revives a hard bounce - a dead mailbox is not proven alive by a consent action. Lift a hard bounce with Clear bounce status. - An
unsubscribedonsms/whatsappis mirrored to the phone-keyed suppression ledger (the opt-out follows the number, org-wide). An email status change is mirrored to the address-keyed ledger for the workspace. - The change is audit-logged with IP, user agent, and the acting admin/key identity.
Clear bounce status
Reset a contact's email bounce state and lift the address-keyed deliverability suppression. This is the sanctioned way to lift a hard bounce (a recipient's own re-subscribe does not).
Endpoint
POST /subscriptions/contacts/:userId/clear-bounce
Request body
| Field | Type | Required | Description |
|---|---|---|---|
reason | string | No | Free-text reason, stored in the audit-log entry |
Example request
curl -X POST https://api-eu1.joryio.com/subscriptions/contacts/665f1c2ab3d4e5f6a7b8c9d0/clear-bounce \
-H "Authorization: Bearer jry_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "reason": "Mailbox restored, confirmed with customer" }'
Response
{ "success": true }
Clears bounceType, bounceCount, lastBounceAt, and sets isValid back to true - and also deletes the address's hard_bounce / complaint rows from the suppressions ledger for this workspace, so the clear applies to every duplicate contact sharing the address.
Get list subscriptions
Return all of a contact's list membership rows (subscribed and unsubscribed).
Endpoint
GET /subscriptions/contacts/:userId/lists
Example request
curl -X GET https://api-eu1.joryio.com/subscriptions/contacts/665f1c2ab3d4e5f6a7b8c9d0/lists \
-H "Authorization: Bearer jry_live_your_api_key"
Response
[
{
"_id": "665f2e11aa22bb33cc44dd55",
"organizationId": "org-uuid",
"workspaceId": "ws-uuid",
"contactId": "665f1c2ab3d4e5f6a7b8c9d0",
"listId": "3f6c1a2e-9d4b-4f0a-8c7e-1b2d3e4f5a6b",
"channel": "email",
"status": "subscribed",
"subscribedAt": "2026-05-14T09:21:07.000Z",
"optInSource": "api",
"createdAt": "2026-05-14T09:21:07.000Z",
"updatedAt": "2026-05-14T09:21:07.000Z"
}
]
Membership is unique per contact + list + channel: the same contact can be subscribed to a list on email and unsubscribed from it on sms, as two separate rows.
Subscribe contact to a list
Subscribe a contact to a list on one channel. Idempotent - repeating the call re-affirms the subscription. This is also the explicit, audited opt-in path that may re-subscribe a contact who previously opted out of the list (bulk add never does).
Endpoint
POST /subscriptions/contacts/:userId/lists/:listId
Path parameters
| Parameter | Type | Description |
|---|---|---|
userId | string | Joryio contact id |
listId | string | Subscription list id (UUID) |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
channel | string | Yes | email, sms, whatsapp, push, or viber |
source | string | No | e.g. api, form, import, preference_center (default api) |
consentText | string | No | Consent text shown at opt-in |
Example request
curl -X POST https://api-eu1.joryio.com/subscriptions/contacts/665f1c2ab3d4e5f6a7b8c9d0/lists/3f6c1a2e-9d4b-4f0a-8c7e-1b2d3e4f5a6b \
-H "Authorization: Bearer jry_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "channel": "email", "source": "api", "consentText": "Weekly newsletter signup" }'
Response
The created or updated membership row:
{
"_id": "665f2e11aa22bb33cc44dd55",
"organizationId": "org-uuid",
"workspaceId": "ws-uuid",
"contactId": "665f1c2ab3d4e5f6a7b8c9d0",
"listId": "3f6c1a2e-9d4b-4f0a-8c7e-1b2d3e4f5a6b",
"channel": "email",
"status": "subscribed",
"subscribedAt": "2026-07-12T10:20:00.000Z",
"optInSource": "api",
"consentText": "Weekly newsletter signup",
"createdAt": "2026-07-12T10:20:00.000Z",
"updatedAt": "2026-07-12T10:20:00.000Z"
}
Unsubscribe contact from a list
Unsubscribe a contact from a list on one channel. The channel is passed in the request body, not the URL.
Endpoint
DELETE /subscriptions/contacts/:userId/lists/:listId
Request body
| Field | Type | Required | Description |
|---|---|---|---|
channel | string | Yes | email, sms, whatsapp, push, or viber |
source | string | No | e.g. api, preference_center (default api) |
Example request
curl -X DELETE https://api-eu1.joryio.com/subscriptions/contacts/665f1c2ab3d4e5f6a7b8c9d0/lists/3f6c1a2e-9d4b-4f0a-8c7e-1b2d3e4f5a6b \
-H "Authorization: Bearer jry_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "channel": "email", "source": "preference_center" }'
Response
{ "success": true }
If the contact had no membership row for this list + channel, an explicit unsubscribed row is created anyway - a consent withdrawal is never silently lost, and the send gate will block list sends to this contact from then on.
Get subscription history
Return the contact's consent audit log, newest first.
Endpoint
GET /subscriptions/contacts/:userId/history
Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | number | 50 | Entries per page (non-numeric values fall back to the default) |
offset | number | 0 | Pagination offset |
Example request
curl -X GET "https://api-eu1.joryio.com/subscriptions/contacts/665f1c2ab3d4e5f6a7b8c9d0/history?limit=50&offset=0" \
-H "Authorization: Bearer jry_live_your_api_key"
Response
{
"items": [
{
"id": "8a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
"organizationId": "org-uuid",
"workspaceId": "ws-uuid",
"contactId": "665f1c2ab3d4e5f6a7b8c9d0",
"channel": "email",
"listId": null,
"action": "unsubscribe",
"previousStatus": "subscribed",
"newStatus": "unsubscribed",
"source": "preference_center",
"ipAddress": "203.0.113.7",
"userAgent": "Mozilla/5.0 ...",
"consentText": null,
"metadata": { "reason": "User requested via support ticket" },
"createdAt": "2026-07-12T10:15:00.000Z"
}
],
"total": 12
}
action is one of subscribe, unsubscribe, resubscribe, bounce, hard_bounce, soft_bounce, complaint, import, api_update. listId is set for list-level changes and null for channel-level ones.
Check channel subscription status
Lightweight boolean check - is the contact sendable on this channel from a consent standpoint?
Endpoint
GET /subscriptions/contacts/:userId/channels/:channel/status
Example request
curl -X GET https://api-eu1.joryio.com/subscriptions/contacts/665f1c2ab3d4e5f6a7b8c9d0/channels/email/status \
-H "Authorization: Bearer jry_live_your_api_key"
Response
{ "subscribed": true }
subscribed is true unless the contact is explicitly unsubscribed on the channel - a contact with no recorded status counts as subscribed. An unknown contact id returns { "subscribed": false } (not 404).
Check email validity
Is the contact's email address deliverable (not marked invalid by a hard bounce)?
Endpoint
GET /subscriptions/contacts/:userId/email-valid
Example request
curl -X GET https://api-eu1.joryio.com/subscriptions/contacts/665f1c2ab3d4e5f6a7b8c9d0/email-valid \
-H "Authorization: Bearer jry_live_your_api_key"
Response
{ "valid": true }
valid is false only when a hard bounce marked the address invalid. An unknown contact id returns { "valid": false } (not 404). Note this reflects deliverability, not consent - an unsubscribed contact with a working address still returns true.
Subscription lists
Lists are the consent categories / topics contacts can subscribe to (newsletter, product updates, and so on). List definitions live under /lists; per-contact membership is managed with the contact consent endpoints above or the bulk endpoints below.
Create list
Endpoint
POST /lists
Request body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | List name (max 255 chars, unique per workspace) |
description | string | No | Description (max 1000 chars) |
channels | string[] | No | Channels the list applies to (default ["email"]) |
isPublic | boolean | No | Show in the preference center (default true) |
type | string | No | marketing or transactional (default marketing) |
requireDoubleOptIn | boolean | No | Require confirmed opt-in (default false) |
Example request
curl -X POST https://api-eu1.joryio.com/lists \
-H "Authorization: Bearer jry_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Weekly Newsletter",
"description": "Our weekly digest of product updates",
"channels": ["email"],
"isPublic": true,
"type": "marketing",
"requireDoubleOptIn": false
}'
Response
{
"id": "3f6c1a2e-9d4b-4f0a-8c7e-1b2d3e4f5a6b",
"organizationId": "org-uuid",
"workspaceId": "ws-uuid",
"name": "Weekly Newsletter",
"description": "Our weekly digest of product updates",
"channels": ["email"],
"isPublic": true,
"type": "marketing",
"requireDoubleOptIn": false,
"isDefault": false,
"displayOrder": 0,
"senderId": null,
"archivedAt": null,
"createdAt": "2026-07-12T10:30:00.000Z",
"updatedAt": "2026-07-12T10:30:00.000Z"
}
isDefault marks the auto-created default Marketing category; senderId is set when the list is a per-number SMS/WhatsApp subscription group bound to a specific sender.
List all lists
Endpoint
GET /lists
Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
includeArchived | string | false | Pass true to include archived lists |
Example request
curl -X GET "https://api-eu1.joryio.com/lists?includeArchived=false" \
-H "Authorization: Bearer jry_live_your_api_key"
Response
An array of list objects (same shape as Create list), newest first, capped at 200.
Get list
GET /lists/:listId
Returns the list object. 404 Not Found if the list does not exist or is archived.
Update list
PUT /lists/:listId
Body: any subset of the Create list fields (name, description, channels, isPublic, type, requireDoubleOptIn). Returns the updated list object.
Archive list
DELETE /lists/:listId
Soft delete - sets archivedAt; membership rows are kept. Returns 204 No Content. Restore with:
POST /lists/:listId/restore
which returns the restored list object.
Get list members
Endpoint
GET /lists/:listId/members
Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
channel | string | - | Optional filter: email, sms, whatsapp, push, viber |
status | string | - | Optional filter: subscribed or unsubscribed |
limit | number | 50 | Rows per page |
offset | number | 0 | Pagination offset |
Example request
curl -X GET "https://api-eu1.joryio.com/lists/3f6c1a2e-9d4b-4f0a-8c7e-1b2d3e4f5a6b/members?channel=email&status=subscribed&limit=50" \
-H "Authorization: Bearer jry_live_your_api_key"
Response
{
"members": [
{
"_id": "665f2e11aa22bb33cc44dd55",
"contactId": "665f1c2ab3d4e5f6a7b8c9d0",
"listId": "3f6c1a2e-9d4b-4f0a-8c7e-1b2d3e4f5a6b",
"channel": "email",
"status": "subscribed",
"subscribedAt": "2026-05-14T09:21:07.000Z",
"optInSource": "api",
"organizationId": "org-uuid",
"workspaceId": "ws-uuid",
"createdAt": "2026-05-14T09:21:07.000Z",
"updatedAt": "2026-05-14T09:21:07.000Z"
}
],
"total": 1234
}
Get list stats
Endpoint
GET /lists/:listId/stats
Example request
curl -X GET https://api-eu1.joryio.com/lists/3f6c1a2e-9d4b-4f0a-8c7e-1b2d3e4f5a6b/stats \
-H "Authorization: Bearer jry_live_your_api_key"
Response
{
"total": 1500,
"byChannel": {
"email": 1180,
"sms": 120,
"whatsapp": 0,
"push": 0,
"viber": 0
},
"subscribed": 1300,
"unsubscribed": 200
}
byChannel counts non-unsubscribed memberships per channel.
Bulk add members
Add up to 10,000 contacts to a list on one channel in a single call.
Endpoint
POST /lists/:listId/members/bulk
Request body
| Field | Type | Required | Description |
|---|---|---|---|
contactIds | string[] | Yes | Joryio contact ids (max 10,000) |
channel | string | Yes | email, sms, whatsapp, push, or viber |
source | string | No | Recorded as the membership's optInSource |
Example request
curl -X POST https://api-eu1.joryio.com/lists/3f6c1a2e-9d4b-4f0a-8c7e-1b2d3e4f5a6b/members/bulk \
-H "Authorization: Bearer jry_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"contactIds": ["665f1c2ab3d4e5f6a7b8c9d0", "665f1c2ab3d4e5f6a7b8c9d1"],
"channel": "email",
"source": "import"
}'
Response
{ "added": 2, "updated": 0, "skippedUnsubscribed": 0 }
Bulk add only creates net-new memberships. Contacts already on the list are left untouched, and contacts who explicitly unsubscribed are never re-subscribed by a bulk add - they are counted in skippedUnsubscribed. Re-subscribing an opted-out contact requires the explicit, audited Subscribe contact to a list call. updated is always 0 (kept for backward compatibility).
Bulk remove members
Unsubscribe up to 10,000 contacts from a list on one channel.
Endpoint
DELETE /lists/:listId/members/bulk
Request body
| Field | Type | Required | Description |
|---|---|---|---|
contactIds | string[] | Yes | Joryio contact ids (max 10,000) |
channel | string | Yes | email, sms, whatsapp, push, or viber |
Example request
curl -X DELETE https://api-eu1.joryio.com/lists/3f6c1a2e-9d4b-4f0a-8c7e-1b2d3e4f5a6b/members/bulk \
-H "Authorization: Bearer jry_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"contactIds": ["665f1c2ab3d4e5f6a7b8c9d0"],
"channel": "email"
}'
Response
{ "removed": 1 }
Removal flips existing membership rows to unsubscribed (rows are kept for the audit trail), so removed contacts are gated from future list sends.
Hosted preference page
Authoring API for the workspace's hosted preference-center page - the page recipients land on from an unsubscribe/preferences link. The public rendering of the page is served by the token-based public endpoints (no API key), which are not part of this reference.
Get the preference-center template
GET /subscriptions/hosted-page/preference-center
Response
{
"type": "preference_center",
"mode": "default",
"html": "",
"redirectUrl": "",
"designJson": null,
"updatedAt": null
}
mode is one of default (Joryio's built-in page), custom (your HTML/Liquid template), dnd (authored in the visual editor; same rendered html), or redirect (record the opt-out, then redirect to redirectUrl).
Update the preference-center template
PUT /subscriptions/hosted-page/preference-center
Request body
| Field | Type | Required | Description |
|---|---|---|---|
mode | string | No | default, custom, dnd, or redirect |
html | string | No | HTML/Liquid body for custom / dnd mode (max 100 KB); must include the {{ preferences_form }} slot |
redirectUrl | string | No | Target for redirect mode (max 2048 chars) |
designJson | object | No | Visual-editor round-trip state (dnd mode only; cleared in other modes) |
Returns the saved template in the same shape as the GET response.
Preview a template
POST /subscriptions/hosted-page/preview
Body: { "html": "..." } - the template to render with sample data. Response: { "html": "<rendered, sanitized html>" }.
Related surfaces (not on this page)
- Recipient-facing endpoints - one-click unsubscribe (
POST /u/:token) and the hosted preference pages are token-authenticated public endpoints for recipients, not API-key endpoints. - Web SDK -
POST /v1/subscriptions/channelandPOST /v1/subscriptions/groupare authenticated with an SDK key (X-SDK-Key), for client-side preference UIs. See the Subscription Management guide. - Provider webhooks - bounce and inbound SMS keyword webhooks (
/webhooks/email/...,/webhooks/sms/...) are signature-verified provider integrations. - Suppressions - the workspace-level do-not-send ledger has its own Suppressions API.
Next Steps
- Users API - create the contact before setting consent
- Suppressions API - the identifier-keyed do-not-send ledger
- Subscription Management guide - concepts, SDK usage, keywords, compliance