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/api - 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/:userIdsubscriptions:read or compliance:read
PUT /subscriptions/contacts/:userId/channels/:channelsubscriptions:write or compliance:write
POST /subscriptions/contacts/:userId/clear-bouncesubscriptions:write or compliance:write
GET /subscriptions/contacts/:userId/listssubscriptions:read or compliance:read
POST /subscriptions/contacts/:userId/lists/:listIdsubscriptions:write or compliance:write
DELETE /subscriptions/contacts/:userId/lists/:listIdsubscriptions:write or compliance:write
GET /subscriptions/contacts/:userId/historycompliance:read
GET /subscriptions/unsubscribe-reasonscompliance:read
GET /subscriptions/contacts/:userId/channels/:channel/statussubscriptions:read or compliance:read
GET /subscriptions/contacts/:userId/email-validsubscriptions:read or compliance:read
POST /listslists:write or settings:write
GET /lists, GET /lists/:listId, GET /lists/:listId/statslists:read or settings:read
GET /lists/:listId/memberscompliance:read
PUT /lists/:listId, DELETE /lists/:listId, POST /lists/:listId/restorelists:write or settings:write
POST /lists/:listId/members/bulk, DELETE /lists/:listId/members/bulklists:write or settings: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​

Contacts are created through POST /users/track, and the same call can set their list subscriptions: each attributes element takes subscriptions (details), applied through the same consent path as the endpoints on this page. 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
pendingAwaiting confirmation: asked to join, has not confirmed the double opt-in yet. Not reachable until confirmed; confirming makes it subscribed. Set by a confirmation flow (for example Delivyr double opt-in), never by the SDKs.

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/api/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. pending (awaiting confirmation) is accepted here too.
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/api/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 on the email channel clears transient soft-bounce state, but never revives a hard bounce or a spam complaint - only Joryio support can lift those.
  • 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.

SMS double opt-in​

When the workspace turned on SMS double opt-in (Settings > Subscriptions), a PUT .../channels/sms with subscribed or optedIn for a contact with no SMS status does not subscribe them: the status is stored as pending and the contact gets one "Reply YES" text. The response shows it:

{ "sms": { "status": "pending", "optInSource": "api", "confirmation": { "requestedAt": "2026-10-17T10:00:00.000Z", "expiresAt": "2026-10-24T10:00:00.000Z", "sentAt": "2026-10-17T10:00:01.000Z" } } }
  • The contact becomes subscribed (with optInSource: "double_opt_in") only by replying YES (or START, or another opt-in word) to your number. No API call can confirm a pending contact (409).
  • Asking again while the contact is pending returns 200 with the same open request - no second text.
  • A contact who opted out, or whose number has an opt-out on it, is treated like a new sign-up (owner decision 2026-10-17): pending and ONE confirmation text, subscribed only after YES; unanswered, it expires back to unsubscribed and nothing else is sent. A contact who had texted STOP also gets the usual warnings entry sms_stop_provider_block; if the provider refuses the text with its STOP code, the contact is unsubscribed again. A hard bounce, spam complaint or organization-wide block is still refused (409).
  • Unanswered requests expire after the configured days (default 7): the SMS status is removed (not unsubscribed).
  • Not applied when the workspace's SMS number is on a Delivyr account - Delivyr runs its own double opt-in.

See SMS double opt-in.


Duplicate contacts and re-subscribing​

  • An unsubscribe applies to every contact with the same email or phone in the workspace: each one's status (or list membership, for a list) is set to unsubscribed and gets a history entry with source shared_identifier.
  • A re-subscribe through the API is allowed and applies to that contact only. Other contacts with the same email or phone stay unsubscribed. The change is recorded with source api and the API key id in metadata.principal.
  • A source in the body that ends in _keyword, or is shared_identifier, complaint_webhook or staff, is recorded as api - only Joryio sets those.
  • A STOP opt-out can be overridden (SMS, WhatsApp, Viber, any provider) with this regular update - no extra flag. It applies to that contact only, and the history records that it overrode a STOP (metadata.overrodeStop) and the API key. A START reply keeps working as before.
  • SMS only: re-subscribing an SMS contact who texted STOP answers 200 OK with a non-error warnings field, because the SMS provider may keep its own block until the contact texts START. WhatsApp and Viber return no warning. The list endpoint (POST /subscriptions/contacts/:userId/lists/:listId, for an SMS list) and the SDK channel endpoint return the same field.
{
"sms": { "status": "subscribed", "optInSource": "api" },
"warnings": [
{
"code": "sms_stop_provider_block",
"channel": "sms",
"message": "This contact opted out by texting STOP. Your SMS provider may keep blocking messages until they text START."
}
]
}
  • A send the SMS provider then rejects because of its own STOP block (for example Twilio 21610 or a Delivyr opted-out rejection) is recorded as blocked by provider (opted out) - not a failure, and not counted toward bounce or failure-rate alerts. The opt-out is applied again, with the history note provider still blocks: re-opted out.
  • Some blocks cannot be lifted by a re-subscribe. The request is refused with 409 Conflict and nothing changes:
codemessage
deliverabilityThis address hard-bounced or marked a message as spam. Only Joryio support can lift that block.
organization_blockThis identifier is blocked for the whole organization. Only Joryio support can lift that block.
verified_identity_required403 - SDK endpoints only (/v1/subscriptions/channel, /v1/subscriptions/group): re-subscribing a person who explicitly opted out (own unsubscribe or list opt-out, a STOP, an opt-out on their email or phone - another contact's included) from a subject that is not verified - a userId without SDK Authentication, or an anonymousId that belongs to an identified person. A first sign-up is accepted; a list that requires double opt-in answers pending: true and sends the confirmation email. Unsubscribing is always allowed.
{ "statusCode": 409, "error": "Conflict", "code": "deliverability", "message": "This address hard-bounced or marked a message as spam. Only Joryio support can lift that block." }

The list endpoint (POST /subscriptions/contacts/:userId/lists/:listId) is never refused for these; a list subscription does not lift them either. See Contacts that share an email or phone.

Clear bounce status​

Reset a contact's soft-bounce counters. A hard bounce or a spam complaint is lifted only by Joryio support: when the address has one, the request is refused with 403 and code: "deliverability".

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/api/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 and lastBounceAt of a soft bounce. It does not delete hard_bounce / complaint rows from the suppressions ledger.


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/api/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/api/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/api/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/api/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", "unsubscribeSource": "preferences_page", "sinceSendMs": 3600000 },
"createdAt": "2026-07-12T10:15:00.000Z",
"changeKind": "unsubscribed",
"changeLabel": "Unsubscribed",
"sourceKind": "preference_center",
"sourceLabel": "Preference center",
"sourcePlatform": null,
"page": null
}
],
"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.

Every unsubscribe entry written since 2026-10-15 carries metadata.unsubscribeSource - how it arrived: header_one_click, page_button, preferences_page, page_everything, api, admin, sdk, import, keyword, complaint, esp_unsubscribe, store_sync, other. header_one_click is the mail app's own unsubscribe button (RFC 8058); page_button is a person pressing Unsubscribe on the hosted page. Where the message is known, metadata.sinceSendMs is the time from its send to the unsubscribe. userAgent is stored without control characters and cut to 256 characters. Entries with metadata.pageMode auto were made by the hosted page without a click, between 2026-10-11 and 2026-10-15.

Consent proof (since 2026-11-02). Every entry also carries changeKind / changeLabel - what the change did: subscribed, resubscribed, unsubscribed, pending_order (a store checkout tick held until an order or the confirmation link), pending_confirmation, withdrawn (the tick unticked in the same checkout), confirmed_by_order, confirmed, confirmation_expired, bounce, complaint, import, updated - and sourceKind / sourceLabel / sourcePlatform - where it came from: store_checkout, store_registration, store_customer_record, store_newsletter, store_sync (with sourcePlatform woocommerce, shopify, magento or prestashop), landing_page, in_app_form, website_form, api, admin, sdk, import, unsubscribe_link, preference_center, bounce, complaint, keyword_stop, keyword_start, double_opt_in, push_permission, journey, email_provider, shared_identifier, contact_merge (a status carried over when two contacts were merged), support, other. The *Label fields are English wording. page is the page the consent was given on where stored (a page or store URL, or a landing page slug), else null. consentText is the exact wording the person was shown. ipAddress is personal data: a caller without pii:view (an API key only with that scope) gets null and ipHidden: true when one was stored. location is where on the store the consent was collected - checkout, block_checkout, registration, customer_record or newsletter - and collectedAt when it was collected (ISO-8601, the store's clock; it can be earlier than createdAt when the tick was held until the order); both null when not recorded.


List recent unsubscribe reasons​

The workspace's recent unsubscribes that carry a reason or were flagged as possibly automatic, newest first. One row per unsubscribe action: when one action left several channels (for example a legacy link that left everything), the channels are grouped into one row.

Endpoint​

GET /subscriptions/unsubscribe-reasons

Query parameters​

ParameterTypeDefaultDescription
limitnumber50Rows per page (max 100)
offsetnumber0Pagination offset
daysnumber90How far back to look (max 365)
flaggedbooleanfalsetrue returns only unsubscribes flagged as possibly automatic

Example request and response​

curl -X GET "https://api-eu1.joryio.com/api/subscriptions/unsubscribe-reasons?limit=50&offset=0&days=90" \
-H "Authorization: Bearer jry_live_your_api_key"
{
"items": [
{
"at": "2026-10-11T09:12:44.000Z",
"contactId": "665f1c2ab3d4e5f6a7b8c9d0",
"email": "[email protected]",
"name": "Dana Levi",
"channels": ["email"],
"listId": null,
"reason": "too_frequent",
"campaignId": "3f2a1b0c-9d8e-4f7a-b6c5-d4e3f2a1b0c9",
"source": "one_click",
"suspectedAutomatic": false,
"suspectedAutomaticReasons": []
}
],
"total": 37
}

reason is the reason the person chose on the preference page (null when none was given). suspectedAutomatic is true when the unsubscribe happened on the hosted page within 10 seconds of the message being sent, or the request looked like scanner traffic; suspectedAutomaticReasons says which. The same flag is stored in the audit entry's metadata (see Get subscription history). name follows the pii:view rules: a caller without that permission gets it masked. Requires compliance:read.


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/api/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/api/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/api/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/api/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/api/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/api/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/api/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/api/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, the mail app's button), the hosted page's Unsubscribe button (POST /u/:token/confirm, a person's click only) 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​