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
| Endpoint | Scope |
|---|---|
GET /subscriptions/contacts/:userId | subscriptions:read or compliance:read |
PUT /subscriptions/contacts/:userId/channels/:channel | subscriptions:write or compliance:write |
POST /subscriptions/contacts/:userId/clear-bounce | subscriptions:write or compliance:write |
GET /subscriptions/contacts/:userId/lists | subscriptions:read or compliance:read |
POST /subscriptions/contacts/:userId/lists/:listId | subscriptions:write or compliance:write |
DELETE /subscriptions/contacts/:userId/lists/:listId | subscriptions:write or compliance:write |
GET /subscriptions/contacts/:userId/history | compliance:read |
GET /subscriptions/unsubscribe-reasons | compliance:read |
GET /subscriptions/contacts/:userId/channels/:channel/status | subscriptions:read or compliance:read |
GET /subscriptions/contacts/:userId/email-valid | subscriptions:read or compliance:read |
POST /lists | lists:write or settings:write |
GET /lists, GET /lists/:listId, GET /lists/:listId/stats | lists:read or settings:read |
GET /lists/:listId/members | compliance:read |
PUT /lists/:listId, DELETE /lists/:listId, POST /lists/:listId/restore | lists:write or settings:write |
POST /lists/:listId/members/bulk, DELETE /lists/:listId/members/bulk | lists:write or settings:write |
GET /subscriptions/hosted-page/preference-center, POST /subscriptions/hosted-page/preview | settings:read |
PUT /subscriptions/hosted-page/preference-center | settings:write |
Two are deliberately not openable to an API key with the subscription scopes:
GET .../history is the consent audit trail - evidence produced for a regulator, not
operational state - and GET /lists/:listId/members discloses contacts. Both keep
compliance:read alone.
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
| Status | Description |
|---|---|
optedIn | Explicit opt-in (e.g. confirmed double opt-in) |
subscribed | Subscribed (single opt-in / default) |
unsubscribed | Opted out |
pending | Awaiting 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).
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/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
| 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. pending (awaiting confirmation) is accepted here too. |
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/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
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.
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(withoptInSource: "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
pendingreturns200with 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):
pendingand ONE confirmation text, subscribed only after YES; unanswered, it expires back tounsubscribedand nothing else is sent. A contact who had texted STOP also gets the usualwarningsentrysms_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
unsubscribedand gets a history entry with sourceshared_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
apiand the API key id inmetadata.principal. - A
sourcein the body that ends in_keyword, or isshared_identifier,complaint_webhookorstaff, is recorded asapi- only Joryio sets those. - A
STOPopt-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 aSTOP(metadata.overrodeStop) and the API key. ASTARTreply keeps working as before. - SMS only: re-subscribing an SMS contact who texted
STOPanswers200 OKwith a non-errorwarningsfield, because the SMS provider may keep its own block until the contact textsSTART. 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
STOPblock (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 Conflictand nothing changes:
code | message |
|---|---|
deliverability | This address hard-bounced or marked a message as spam. Only Joryio support can lift that block. |
organization_block | This identifier is blocked for the whole organization. Only Joryio support can lift that block. |
verified_identity_required | 403 - 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
| 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/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
| 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/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
| 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/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
| 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/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
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | number | 50 | Rows per page (max 100) |
offset | number | 0 | Pagination offset |
days | number | 90 | How far back to look (max 365) |
flagged | boolean | false | true 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",
"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
| 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/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
| Parameter | Type | Default | Description |
|---|---|---|---|
includeArchived | string | false | Pass 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
| 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/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
| 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/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
| 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/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
| 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, 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/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