Skip to main content

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

EndpointScope
GET /subscriptions/contacts/:userIdcompliance:read
PUT /subscriptions/contacts/:userId/channels/:channelcompliance:write
POST /subscriptions/contacts/:userId/clear-bouncecompliance:write
GET /subscriptions/contacts/:userId/listscompliance:read
POST /subscriptions/contacts/:userId/lists/:listIdcompliance:write
DELETE /subscriptions/contacts/:userId/lists/:listIdcompliance:write
GET /subscriptions/contacts/:userId/historycompliance:read
GET /subscriptions/contacts/:userId/channels/:channel/statuscompliance:read
GET /subscriptions/contacts/:userId/email-validcompliance:read
POST /listssettings:write
GET /lists, GET /lists/:listId, GET /lists/:listId/statssettings:read
GET /lists/:listId/memberscompliance:read
PUT /lists/:listId, DELETE /lists/:listId, POST /lists/:listId/restoresettings:write
POST /lists/:listId/members/bulk, DELETE /lists/:listId/members/bulksettings:write
GET /subscriptions/hosted-page/preference-center, POST /subscriptions/hosted-page/previewsettings:read
PUT /subscriptions/hosted-page/preference-centersettings: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

StatusDescription
optedInExplicit opt-in (e.g. confirmed double opt-in)
subscribedSubscribed (single opt-in / default)
unsubscribedOpted 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).

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.


Get contact subscriptions

Return a contact's per-channel consent state plus their list memberships.

Endpoint

GET /subscriptions/contacts/:userId

Path parameters

ParameterTypeDescription
userIdstringJoryio 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

ParameterTypeDescription
userIdstringJoryio contact id
channelstringemail, sms, whatsapp, push, or viber

Request body

FieldTypeRequiredDescription
channelstringYesMust match the URL channel (the URL value wins if they differ)
statusstringYesoptedIn, subscribed, or unsubscribed
sourcestringNoWhere the change came from, e.g. api, preference_center (default api)
consentTextstringNoThe consent text shown at opt-in (stored for compliance)
reasonstringNoFree-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 unsubscribed on sms/whatsapp is 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

FieldTypeRequiredDescription
reasonstringNoFree-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

ParameterTypeDescription
userIdstringJoryio contact id
listIdstringSubscription list id (UUID)

Request body

FieldTypeRequiredDescription
channelstringYesemail, sms, whatsapp, push, or viber
sourcestringNoe.g. api, form, import, preference_center (default api)
consentTextstringNoConsent 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

FieldTypeRequiredDescription
channelstringYesemail, sms, whatsapp, push, or viber
sourcestringNoe.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

ParameterTypeDefaultDescription
limitnumber50Entries per page (non-numeric values fall back to the default)
offsetnumber0Pagination 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

FieldTypeRequiredDescription
namestringYesList name (max 255 chars, unique per workspace)
descriptionstringNoDescription (max 1000 chars)
channelsstring[]NoChannels the list applies to (default ["email"])
isPublicbooleanNoShow in the preference center (default true)
typestringNomarketing or transactional (default marketing)
requireDoubleOptInbooleanNoRequire 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

ParameterTypeDefaultDescription
includeArchivedstringfalsePass 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

ParameterTypeDefaultDescription
channelstring-Optional filter: email, sms, whatsapp, push, viber
statusstring-Optional filter: subscribed or unsubscribed
limitnumber50Rows per page
offsetnumber0Pagination 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

FieldTypeRequiredDescription
contactIdsstring[]YesJoryio contact ids (max 10,000)
channelstringYesemail, sms, whatsapp, push, or viber
sourcestringNoRecorded 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

FieldTypeRequiredDescription
contactIdsstring[]YesJoryio contact ids (max 10,000)
channelstringYesemail, 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

FieldTypeRequiredDescription
modestringNodefault, custom, dnd, or redirect
htmlstringNoHTML/Liquid body for custom / dnd mode (max 100 KB); must include the {{ preferences_form }} slot
redirectUrlstringNoTarget for redirect mode (max 2048 chars)
designJsonobjectNoVisual-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>" }.


  • 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/channel and POST /v1/subscriptions/group are 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