Skip to main content

Subscription Management

This document describes Joryio's comprehensive subscription and consent management system, which handles channel-level subscriptions, list/topic-based subscriptions, RFC 8058 List-Unsubscribe headers, bounce handling, and compliance features.

Table of Contents​

  1. Overview
  2. Data Models
  3. Channel Subscriptions
  4. Web SDK Integration
  5. Subscription Lists
  6. Double opt-in
  7. Per-number subscription groups (SMS & WhatsApp)
  8. List-Unsubscribe Header (RFC 8058)
  9. Hosted preference page and unsubscribe
  10. Preference Page (Custom Design)
  11. Bounce Handling
  12. Liquid Template Variables
  13. Segment Filters
  14. API Reference
  15. Configuration
  16. Compliance

Overview​

Joryio's subscription management system provides:

  • Channel-level subscriptions: Track opt-in/opt-out status for email, SMS, WhatsApp, push notifications, and Viber
  • List-based subscriptions: Allow users to subscribe to specific topics or mailing lists
  • RFC 8058 compliance: One-click unsubscribe headers for improved email deliverability
  • Bounce handling: Automatic handling of soft and hard bounces
  • Audit logging: Complete history of subscription changes for compliance
  • Segment filtering: Target users based on subscription status

Data Models​

Channel Subscription (per user)​

Each user has subscription status for each communication channel:

interface ChannelSubscription {
status: 'optedIn' | 'subscribed' | 'unsubscribed';
optInDate?: Date;
optOutDate?: Date;
optInSource?: string; // 'api', 'web_form', 'import', 'manual'
consentText?: string; // Consent text shown at opt-in
}

interface EmailSubscription extends ChannelSubscription {
bounceType?: 'soft' | 'hard' | null;
bounceCount?: number;
lastBounceAt?: Date;
isValid?: boolean; // false if hard bounced
}

interface UserSubscriptions {
email?: EmailSubscription;
sms?: ChannelSubscription;
whatsapp?: ChannelSubscription;
push?: ChannelSubscription;
viber?: ChannelSubscription;
}

Subscription List​

Lists are topics or categories users can subscribe to:

interface SubscriptionList {
id: string;
organizationId: string;
workspaceId: string;
name: string;
description?: string;
channels: ('email' | 'sms' | 'whatsapp' | 'push' | 'viber')[];
isPublic: boolean; // Show in preference center
type: 'marketing' | 'transactional';
requireDoubleOptIn: boolean;
archivedAt?: Date;
createdAt: Date;
updatedAt: Date;
}

List Membership​

Tracks which users are subscribed to which lists:

interface ListSubscription {
contactId: string;
listId: string;
channel: 'email' | 'sms' | 'whatsapp' | 'push' | 'viber';
status: 'optedIn' | 'subscribed' | 'unsubscribed';
subscribedAt?: Date;
unsubscribedAt?: Date;
optInSource?: string;
}

Channel Subscriptions​

Status Types​

StatusDescription
optedInUser completed double opt-in confirmation
subscribedUser is subscribed (single opt-in)
unsubscribedUser has opted 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.

Updating Channel Status​

// Via API
await subscriptionsApi.updateChannelSubscription(userId, 'email', {
status: 'unsubscribed',
source: 'preference_center',
reason: 'User requested via preference center'
});

The Subscribed event​

Whenever a person becomes subscribed, Joryio records the platform event message.subscribed - the counterpart of message.unsubscribed. Use it as a journey trigger (for example a welcome series after a newsletter signup) or in a segment ("performed message.subscribed in the last 7 days").

  • Once per real change. It fires when a channel or a list goes from no status, unsubscribed or pending to subscribed / optedIn. Saving the same consent again, or a contact who is already subscribed, records nothing. With double opt-in it fires when the person confirms, not when they are asked.
  • Properties: channel, scope (channel / list), listId, source, platform, optInSource, status, previousStatus, resubscribe. listId is set for a list; platform names the store for a store signup; resubscribe is true after an earlier opt-out.
  • source - how it happened: store_newsletter, store_checkout, store_registration, store_customer_record, landing_page_form, in_app_form, website_form, api, dashboard, sdk, preference_center, double_opt_in, keyword, push_permission, journey. keyword is a START reply, push_permission the first push token on file, journey an Update user step, double_opt_in a confirmed pending opt-in.
  • Not recorded for history. Contact imports, warehouse syncs, bulk list adds and a store's initial sync or scheduled reconciliation write the subscription status but no event: those people were already subscribed, they did not just sign up, and a large import must not start thousands of welcome journeys. Live store signals - a newsletter signup, a checkout box, a customer update - do record it.
  • Consent rules are unchanged. A re-subscribe applies to that one contact only, and a complaint or hard bounce can only be lifted by Joryio staff - a refused re-subscribe records nothing. Like every event, it belongs to one workspace.

The Unsubscribed event. message.unsubscribed records every opt-out: an unsubscribe link or header, the preference page, a STOP reply and - since 2026-11-02 - a team member in the dashboard (unsubscribeSource admin), the API (api), the SDK (sdk) and a store opt-out (store_sync). It is a record of the opt-out for segments, conditions and Data Streaming; a journey does not start on it.

Best Practices​

  • Always record the source of subscription changes
  • Store consent text when users opt in
  • For GDPR compliance, use double opt-in for European users

Web SDK Integration​

The Joryio Web SDK allows you to manage user subscription preferences directly from your website or web application.

Installation​

<!-- Via script tag (served from the Joryio API) -->
<script src="https://sdk.joryio.com/web/1/joryio.min.js"></script>

<!-- Or via npm -->
npm install @joryio/web-sdk

Subscription Status Types​

The SDK provides three subscription status values:

StatusDescription
SubscriptionStatus.OPTED_INUser has explicitly opted in (e.g., confirmed double opt-in)
SubscriptionStatus.SUBSCRIBEDUser is subscribed but hasn't explicitly opted in
SubscriptionStatus.UNSUBSCRIBEDUser has opted out

Setting Channel Subscriptions​

import { JoryioSDK, SubscriptionStatus } from '@joryio/web-sdk';

// Initialize SDK
const sdk = new JoryioSDK({ sdkKey: 'jry_sdk_web_...' });

// Set subscription for a single channel (write-only for security - no getters)
sdk.user.setSubscription('email', SubscriptionStatus.OPTED_IN);
sdk.user.setSubscription('sms', SubscriptionStatus.SUBSCRIBED);
sdk.user.setSubscription('push', SubscriptionStatus.UNSUBSCRIBED);
sdk.user.setSubscription('whatsapp', SubscriptionStatus.SUBSCRIBED);

// Set multiple channels at once
sdk.user.setSubscriptions({
email: SubscriptionStatus.OPTED_IN,
sms: SubscriptionStatus.UNSUBSCRIBED,
whatsapp: SubscriptionStatus.SUBSCRIBED,
push: SubscriptionStatus.OPTED_IN
});

Managing Subscription Groups (Lists)​

// Add user to a subscription group/list
sdk.user.addToSubscriptionGroup('newsletter-list-id', 'email');
sdk.user.addToSubscriptionGroup('product-updates-id', 'push');

// Remove user from a subscription group/list
sdk.user.removeFromSubscriptionGroup('newsletter-list-id', 'email');

// The channel parameter is optional, defaults to 'email'
sdk.user.addToSubscriptionGroup('weekly-digest-id');

Security Notes​

The SDK User object is write-only for security reasons:

  • No getSubscriptions() method - Prevents other sites/scripts from reading user subscription data
  • No getter methods - All operations are one-way writes to the backend
  • SDK key authentication - All requests are authenticated using your SDK key
  • The SDK always acts as "the current user" - setSubscription / addToSubscriptionGroup take no target id; they apply to whoever the SDK is currently identified as (an anonymous visitor's own id, or the user you passed to identify()). There is no way for one visitor to change another's subscriptions through the SDK.
  • With SDK Authentication enabled, a subscription change that names a user must carry that user's token. An anonymous visitor's own change is accepted - they have no token to show - but an anonymousId that already belongs to an identified user is refused, so one person's consent cannot be changed through another's leftover id. With Strict SDK identity on, anonymous subscription changes are refused too - call identify(userId) first. The exception is an unsubscribe, which is always accepted (below).
  • Unsubscribes are always delivered. A channel unsubscribed and a group unsubscribe are accepted for anyone the SDK acts for - an anonymous visitor, a person who opted out of tracking or declined tracking consent, and under Strict SDK identity. The SDKs keep them queued and retry them through optOut(), a denied consent and wipeData(); everything else (subscribing, attributes, events) stops. The one rule that still applies: under SDK Authentication, an anonymousId that belongs to an identified user cannot change that user's consent without their token. Details: Withdrawals are always delivered. They are accepted even while the account's event ingestion is paused for billing.
  • First sign-ups are open; re-subscribing someone who opted out is not. Like any client-side SDK, the key is public, so without SDK Authentication a userId is only a claim. A first sign-up (a channel or a list the person never opted out of) is accepted for any user. Re-subscribing a person who explicitly opted out - their own unsubscribe or list opt-out, a STOP reply, or an opt-out on their email or phone made by another contact - is accepted only for a user verified by SDK Authentication or for the visitor's own anonymous profile; otherwise the request gets 403 with code: "verified_identity_required" (re-subscribe them from your server with the API instead). An anonymousId that belongs to an identified person counts like an unverified userId: a first sign-up for that person is accepted, re-subscribing them after an opt-out is not. Recommended: turn on double opt-in for lists fed by public forms or apps - an SDK subscribe to such a list sends the confirmation email instead of subscribing.
  • Subscriptions follow the person at sign-in. When an anonymous visitor identifies and their anonymous profile is merged into an existing profile, both their channel subscriptions and their list memberships move across. Where the two profiles disagree, an unsubscribe on either side wins. A merge never erases a status the surviving profile already has; a status it carries over (an opt-out) is written to that profile's subscription history with the source identity_merge.

TypeScript Support​

import {
JoryioSDK,
SubscriptionStatus,
SubscriptionChannel,
SubscriptionPreferences
} from '@joryio/web-sdk';

const sdk = new JoryioSDK({ sdkKey: 'jry_sdk_web_...' });

// Type-safe subscription updates
const preferences: SubscriptionPreferences = {
email: SubscriptionStatus.OPTED_IN,
sms: SubscriptionStatus.UNSUBSCRIBED,
};

sdk.user.setSubscriptions(preferences);

// Type-safe channel selection
const channel: SubscriptionChannel = 'email';
sdk.user.setSubscription(channel, SubscriptionStatus.OPTED_IN);

Example: Preference Settings Page​

// On your settings page
function handleSubscriptionToggle(channel, isEnabled) {
sdk.user.setSubscription(
channel,
isEnabled ? SubscriptionStatus.SUBSCRIBED : SubscriptionStatus.UNSUBSCRIBED
);
}

// Usage
handleSubscriptionToggle('email', true); // Subscribe to email
handleSubscriptionToggle('sms', false); // Unsubscribe from SMS

Example: Newsletter Signup​

function subscribeToNewsletter(email) {
// First identify the user
sdk.identify(email);
sdk.setAttributes({ email: email });

// Then subscribe to the newsletter list
sdk.user.setSubscription('email', SubscriptionStatus.OPTED_IN);
sdk.user.addToSubscriptionGroup('newsletter-list-id', 'email');
}

Subscription Lists​

Creating a List​

const list = await listsApi.create({
name: 'Weekly Newsletter',
description: 'Our weekly digest of product updates',
channels: ['email'],
isPublic: true, // Show in preference center
type: 'marketing',
requireDoubleOptIn: false
});

Managing Members​

// Subscribe a user to a list
await subscriptionsApi.subscribeToList(userId, listId, 'email', {
source: 'api',
consentText: 'Weekly newsletter signup'
});

// Unsubscribe a user
await subscriptionsApi.unsubscribeFromList(userId, listId, 'email');

// Bulk operations (never re-subscribe contacts who opted out)
await listsApi.bulkAddMembers(listId, contactIds, 'email');
await listsApi.bulkRemoveMembers(listId, contactIds, 'email');

Public vs Private Lists​

  • Public lists (isPublic: true): Shown in the preference center, users can self-manage
  • Private lists (isPublic: false): Only manageable by admins, not shown to users

Double opt-in​

Double opt-in adds a confirmation step to a list: the person fills in a form, gets one email with a signed link, and is added to the list only after they click it. It applies to lists joined through a landing page form or an in-app message form; a subscription written through the API or a bulk import is added directly.

The workspace default​

Settings > Subscriptions > Double opt-in holds two controls:

  • Enable double opt-in by default for new lists - off by default. It is a default for new lists, not a policy: a list created while it is on starts with "Require double opt-in" checked, and changing the toggle later does not touch existing lists.
  • Confirmation email template - "Built-in confirmation email" or one of the workspace's email templates (see below).

The per-list override​

Every list carries its own Require double opt-in checkbox (Subscriptions > Lists, in the create and edit dialog; the lists table shows it as "Required" or "Optional"). It always wins over the workspace default - an explicit choice on the list is never overwritten by the setting. Through the API the field is requireDoubleOptIn; leave it out of the create request to take the workspace default, or pass true / false to decide for that list.

What a pending subscription looks like​

When a form submission names a list that requires confirmation:

  • The person's profile is created or updated with the form's values as usual, but the list membership is not written - nothing is stored for the list until they click. There is no "pending" member row to find on the list.
  • The form's thank-you message gets "Check your inbox to confirm your subscription." appended, in the page's language.
  • The form-submitted event carries listsSubscribed (lists joined immediately) and listsPending (lists waiting for confirmation), so a journey can tell the two apart.
  • One confirmation email is sent for all the pending lists together, as a transactional email through the workspace's own email setup. The same person asking for the same lists again within the hour does not get a second email.
  • The link is valid for 7 days. Clicking it subscribes the person to every list the link names with source double_opt_in and shows a small confirmation page in the form's language. An expired or altered link shows "This confirmation link is not valid any more. Fill in the form again to get a new one."

The confirmation email​

Without a template chosen, Joryio sends its built-in confirmation email in the page's language (English, Hebrew, Spanish, Greek or Arabic; anything else falls back to English): a subject line "Confirm your subscription", the list names, a "Confirm subscription" button and a line telling the recipient to ignore the email if it was not them.

To use your own design, pick one of your email templates as the Confirmation email template. It is rendered with these variables:

VariableValue
{{ confirm_url }}The signed confirmation link. Required - a template that does not reference confirm_url is not used and the built-in email is sent instead, because a confirmation email without its link can never be confirmed.
{{ list_names }}The pending list names, comma separated ("Weekly Newsletter, Product Updates").
{{ lists }}The pending lists as objects with id and name, for a loop.
{{ brand }}The workspace name (or "us" when it has none).
{{ language }}The language code of the form's page, e.g. en.

The template's subject line is rendered with the same variables; a template without a subject uses the built-in one. The template is also skipped, with a warning in the logs, when it no longer exists or fails to render - the person still receives the built-in email. Person variables ({{ user.first_name }} and the rest) are available as in any transactional email.

<p>You asked to receive updates from {{ brand }}:</p>
<ul>
{% for list in lists %}
<li>{{ list.name }}</li>
{% endfor %}
</ul>
<p><a href="{{ confirm_url }}">Confirm subscription</a></p>
<p>If this was not you, ignore this email and nothing changes.</p>

SMS double opt-in​

SMS double opt-in asks every new SMS subscriber to confirm from their own phone before they get any marketing text. It works with every SMS provider and is off by default.

Turning it on​

Settings > Subscriptions > SMS double opt-in:

  • Require confirmation for new SMS subscribers - on or off for the workspace.
  • Brand name - replaces {{brand}} in the texts. Empty = the workspace name.
  • Confirmation text - default: {{brand}}: Reply YES to receive texts. Msg & data rates may apply. Reply STOP to opt out. Saving is refused unless the text names your brand ({{brand}} or the brand name), tells the contact to reply YES (or another opt-in word from the keyword list, such as START, SI or כן) and tells them how to opt out (STOP or another opt-out word). At most 320 characters.
  • Reply when confirmed - on by default: {{brand}}: You're subscribed to texts. Msg & data rates may apply. Reply STOP to opt out. It must name the brand and the opt-out too.
  • Expire after - days a request stays open, 1-30, default 7.
  • Send one reminder - off by default; when on, one reminder (the same confirmation text) after the hours you set, before the request expires.

What happens to a new SMS subscriber​

When the setting is on, a new SMS opt-in from any source that is not the contact's own confirmation - a form or landing page, the API, the SDKs, a store sync, a contact import or a warehouse sync - is stored as Awaiting confirmation (pending) instead of subscribed, and the contact gets one confirmation text:

  • It is sent from the workspace's default SMS number - the number the contact would hear from - through the regular SMS pipeline, as a system message: it is not a campaign, is charged like any SMS and appears in the SMS logs. It is never sent to a number that opted out (STOP), and it respects quiet hours the way a transactional message does (the "transactional bypass" toggle decides); a text that falls in a quiet window waits for the window to end.
  • One request sends one text (plus the optional reminder). Asking again while the contact is still awaiting confirmation does not send another.
  • A contact awaiting confirmation receives no campaign or journey SMS, in any audience mode.
  • The contact page shows "Awaiting confirmation" on the SMS card, with when the text was sent and when the request expires.

Confirming, declining and expiry​

  • YES (or any opt-in word from the shared keyword list - START, YES, SI, SÍ, OUI, JA, ΝΑΙ, نعم, כן and your own additions) from the contact's phone to your number makes them subscribed, with opt-in source double_opt_in. Only contacts awaiting confirmation in the workspace that owns the receiving number are confirmed. If "Reply when confirmed" is on, they get the confirmation reply once. A YES from someone who is not awaiting confirmation follows the usual START rules.
  • STOP while awaiting confirmation makes them unsubscribed; nothing more is sent.
  • With no answer, the request expires after the set number of days: the SMS status goes back to empty (never "unsubscribed" - nobody opted out) and the history shows "Confirmation expired". A store or warehouse sync does not ask again after an expiry; a new sign-up through a form, the API or the SDK does.

Opted-out contacts​

With SMS double opt-in on, re-subscribing a contact who opted out - by STOP or any other SMS opt-out, through the API, the dashboard, the SDK or an import with Override unsubscribes - is treated like a new sign-up: the contact becomes Awaiting confirmation and gets one confirmation text, and is subscribed only after replying YES. A second request while they are awaiting confirmation sends nothing. If they do not reply, the request expires and they are unsubscribed again; nothing else is sent (no reminder). A contact who had texted STOP still carries the usual warning (in the API response as sms_stop_provider_block, on the contact page and in the import summary): the SMS provider may block the confirmation text until they text START, and if it refuses the text with its own STOP code (for example Twilio 21610, Delivyr code 1) the contact goes back to unsubscribed with the history note "provider still blocks: re-opted out". Imports without the override box and store syncs never re-subscribe an opted-out contact, so they are never texted.

Imports​

The import page says when SMS double opt-in is on. Each new SMS opt-in in the file then becomes Awaiting confirmation and gets one confirmation text (within about a minute of the import). With Override unsubscribes, contacts in the file who opted out of SMS are treated the same way - one text, subscribed only after YES, back to unsubscribed if they do not reply (see above); without it they stay opted out and get no text. Tick Already confirmed (I have proof) only if you hold proof the people in the file already confirmed their SMS opt-in; they are then written subscribed with no text. Both boxes need the compliance permission.

Delivyr accounts​

Delivyr runs its own double opt-in. When the workspace's SMS number is on a Delivyr account, Joryio does not send a second confirmation: new SMS opt-ins are recorded as usual, and Delivyr holds each text to an unconfirmed number (failure reason "Not confirmed yet") and asks the contact itself until they reply YES there. A contact Delivyr reports as awaiting confirmation is confirmed in Joryio by their YES through the Delivyr webhook.


Per-number subscription groups (SMS & WhatsApp)​

A subscription list that is bound to a specific sender - i.e. the list carries a senderId - acts as a per-number subscription group. For SMS the group is per number; for WhatsApp the group is per WABA (WhatsApp Business Account). This lets a contact opt out of messages from one number/WABA while remaining subscribed to others.

How opt-outs are scoped and stored​

  • Opt-outs are stored per group. Each per-number group tracks its own opt-out state, separate from the contact's global channel consent and from other numbers/WABAs.
  • Both campaign sends AND journey sends honor opt-outs. Before sending, the system checks the recipient's opt-out status for the sending number's / WABA's group. If the contact has opted out of that group, the send is skipped. In a journey the contact still advances to the next step - only the message is skipped, not the journey.
  • SMS default number. When you send from the workspace's default number, opt-outs scope to that default number's group.

Inbound STOP / START scoping​

When a recipient replies with a keyword, the keyword is applied to the specific number (SMS) / WABA (WhatsApp) the message arrived at:

  • STOP (and every other opt-out keyword except STOPALL) scopes to the per-number / per-WABA group - it opts the contact out of that one number's/WABA's messages only.
  • STOPALL is channel-wide - it opts the contact out of every number/WABA on that channel.
  • START re-subscribes the contact to that same number's / WABA's group.
An inbound webhook URL is bound to one workspace

Workspaces are separate brands, so a STOP opts the contact out of that brand - not of the whole account. That scope comes from the inbound webhook URL, which carries one workspace.

Joryio cannot work the brand out from the message itself. Senders are identified by name (for example Acme), while a reply arrives on a phone number - there is nothing to match the two on.

So every reply and opt-out that reaches a given inbound URL is recorded in that URL's workspace. If one provider account serves more than one brand, configure a separate inbound URL per number in the provider portal, or give each brand its own provider account. Otherwise a STOP meant for Brand B is recorded against Brand A, and Brand B keeps sending.

Keywords​

SMS inbound keyword handling is built from three layers: an always-on English floor, localized defaults (on by default), and your own custom additions. All of them are merged at match time.

English floor (always on, non-removable)

These are required for FCC / CTIA compliance and can never be turned off:

TypeKeywords
Opt-outSTOP, STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT, REVOKE, OPTOUT, OPT OUT, OPT-OUT
Opt-inSTART, YES, UNSTOP, SUBSCRIBE, OPTIN
HelpHELP, INFO
FCC April 2025 floor

REVOKE and OPTOUT are part of the opt-out floor (FCC's April 2025 SMS consent-revocation rule). They are always honored - you don't need to add or enable them.

Localized defaults (on by default, additive)

Common opt-out / opt-in / help words in other languages are recognized out of the box, so a contact can opt out in their own language. Examples:

LanguageOpt-outOpt-inHelp
Hebrewהסר, הסרה, עצור, ביטול, הפסקהתחל, הצטרף, כןעזרה, מידע
SpanishPARE, BASTA, CANCELAR, ALTOSI, ALTAAYUDA
FrenchARRET, ARRÊT, DESABONNEROUIAIDE
GermanSTOPP, ABBESTELLENJAHILFE
PortuguesePARAR, SAIR--

Custom additions (per organization)

Under Settings → SMS / Subscriptions you can add your own opt-out, opt-in, and help keywords (and your own channel-wide opt-out keywords). The floor and localized defaults are shown read-only alongside the editable custom list, so you only manage your additions. SMS keywords are configured at the organization level. They apply to every workspace in the organization, so changing them takes the company-level org_settings:write permission; workspace members without it see the list read-only.

WhatsApp

TypeKeywords
Opt-outSTOP, STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT, OPTOUT, OPT-OUT
Opt-inSTART, UNSTOP, SUBSCRIBE, YES, OPTIN, OPT-IN

Only STOPALL is channel-wide; every other opt-out keyword above scopes to the per-number / per-WABA group. The same applies to any custom channel-wide opt-out keywords you configure for SMS - they behave like STOPALL, while ordinary opt-out keywords stay scoped to the number the message arrived at.

How matching tolerates "de minimis" variances​

Inbound matching is case- and punctuation/whitespace-tolerant, in line with CTIA's guidance that small ("de minimis") variations must still be honored. Before matching, both the first word and the whole message body are normalized: surrounding punctuation and whitespace are stripped and ASCII letters are upper-cased. So Stop, STOP!, " stop ", and STOP. all count as STOP.

Normalization is non-ASCII safe - Hebrew, Arabic, and other scripts have no upper/lower case, so they pass through unchanged (הסר stays הסר); only the surrounding punctuation/whitespace is trimmed.

Re-subscribing after an opt-out​

How a contact comes back depends on how they opted out:

  • Texted STOP (or another opt-out keyword). The contact can text START (or another opt-in keyword) from that same phone, which lifts the opt-out for the number. You can also re-subscribe that one contact explicitly - through the API, an SDK, the contact page, the person's own preference page or forms, or an import with "Override unsubscribes" ticked; see Contacts that share an email or phone. For SMS, the provider (e.g. Twilio) usually keeps its own block on the number, which no API clears: until the contact texts START, the provider may keep rejecting your messages.
  • A store opt-in. Only in the cases in Store marketing consent - for example a logged-in customer ticking the marketing box at checkout after unsubscribing by link.
  • Opted out on a hosted page / preference center → can re-opt-in on-site. A contact who unsubscribed through a link or preference center (not by texting) can re-subscribe the same way - for example via a {{ resubscribe_url }} or by re-enabling SMS in the preference center.

Precedence and fail-safe​

  • Global channel unsubscribe overrides groups. A global channel unsubscribe - e.g. the contact's whole SMS or WhatsApp channel set to unsubscribed - is a hard kill-switch that overrides any per-group subscription. If the channel is globally unsubscribed, no per-number group can re-enable sends to that contact.
  • Fail-safe to channel-wide. If the per-number / per-WABA group cannot be resolved, the system fails safe and applies the opt-out channel-wide rather than risk continuing to message a contact who tried to opt out.

Inbound security​

  • Inbound webhooks are signature-verified; forged STOP / START messages are rejected.
  • Providers that do not support inbound signature verification are rejected for inbound keywords.
One number belongs to one workspace

A sending number - an SMS number, a WhatsApp number or a Viber sender - can be connected to one workspace only in your organization. Other organizations never affect your numbers: an inbound message is matched only against the numbers of the organization whose provider account received it, and WhatsApp numbers are confirmed with Meta when they are connected. Connecting a number that is already connected elsewhere is refused; disconnect it there first. Inbound STOP, START, HELP and replies are applied in the workspace the number belongs to, and a message delivered for a different workspace is refused rather than guessed. Alphanumeric sender names cannot receive replies, so they are not limited this way.

SMS numbers on a Twilio or Vonage account are confirmed the same way: the provider must list the number in the connected account, or the number is not connected. Numbers on other providers cannot be confirmed and are marked Not verifiable - the one-workspace rule above still applies to them.

Phone opt-outs follow the number within a workspace, like email opt-outs follow the address: a duplicate contact with the same number in that workspace is blocked too, and a START lifts the block in its own workspace only.

Alphanumeric sender names cannot receive STOP

A per-number group only works on a sender that can receive replies. A phone number is two-way - recipients can reply, so STOP / opt-out works. An alphanumeric sender name is one-way - recipients cannot reply, so STOP / opt-out keywords will not work on it. Whether alphanumeric sender names are available depends on your SMS provider setup.

An SMS or Viber message step in a journey carries an unsubscribe link whose reach you can choose per node. The "From" sender defines the subscription list, so by default the unsubscribe link (and an inbound STOP) removes the contact from that sender's list only - they stay reachable from your other senders.

On the SMS and Viber message nodes, the "Unsubscribe link removes from:" control offers:

ChoiceBehavior
This sender's list (default)The unsubscribe link opts the contact out of the sending number's / sender's list only - matching how a texted STOP is scoped.
All SMS / All Viber (global)The unsubscribe link opts the contact out of the whole channel - equivalent to STOPALL.

The default (sender's list) is the least-surprising, most-granular choice and keeps the unsubscribe link consistent with inbound STOP scoping. Widen it to global only when a node genuinely represents a channel-wide opt-out. This mirrors the email List-Unsubscribe scope (global vs list) - the same global-vs-list model, applied per node for SMS and Viber.


Email opt-outs follow the address (duplicate contacts)​

The same email address can legitimately belong to more than one contact in a workspace - for example a person imported twice, or created through different sources. Email unsubscribes and hard bounces are recorded against the email address (scoped to the workspace), not just the single contact record that received the message. This matches how mature engagement platforms treat email consent, and it means an unsubscribe is honored for the person, not lost because a duplicate record still looked subscribed.

What this means​

  • Unsubscribe covers every duplicate. When someone unsubscribes - from your unsubscribe link, the one-click List-Unsubscribe header, a preference page, or a spam complaint - all contacts in that workspace with the same email are suppressed, not only the one that was mailed.
  • Global vs. list. A global unsubscribe suppresses the address channel-wide (every campaign and journey). A list/topic unsubscribe suppresses the address for that list only - the person stays reachable on other lists.
  • Hard bounces follow the address too. A hard bounce suppresses the address workspace-wide, so a duplicate contact can't keep sending to a dead mailbox. It is not lifted by a re-subscribe, the API or an import - only by Joryio support (or a genuine email change). See Contacts that share an email or phone.
  • Workspace-scoped. Suppression binds to the workspace that mailed the address. An unsubscribe in one workspace does not silence the address in a different workspace of the same account.
  • Transactional override. A send configured to reach all contacts (the transactional/all preference) still bypasses consent opt-outs, but a hard-bounced address is never mailed - there is no point sending to a dead mailbox.
  • Spam complaints stop transactional mail too. When someone marks your email as spam, the address stops receiving marketing and transactional email (receipts, password resets) in that workspace - unless Joryio support has enabled transactional sending for your organization, in which case complaints stop marketing only. If you want that, send transactional mail from a separate sending subdomain and contact support. An unsubscribe never stops transactional mail; a hard bounce always does.

Both campaign sends and journey sends check this at send time, so a duplicate contact created after the opt-out is still suppressed.


Contacts that share an email or phone​

Two or more contacts in one workspace can share an email address or a phone number - a person imported twice, or two people who really do share one inbox or phone (for example a couple using one family email). Joryio applies these rules to them:

  • An unsubscribe reaches every contact with that email or phone. Whatever the source - an unsubscribe link, the one-click header, the preference page, a change on the contact page, the API, an SDK, an import, a STOP reply or a spam complaint - every contact in the workspace with the same email (for email) or phone (for SMS, WhatsApp and Viber) is unsubscribed too. Their own subscription status (or, for a list unsubscribe, their list membership) is updated, and each gets a history entry: unsubscribed via another contact with the same email (or phone).
  • The block stays for contacts created later. The opt-out is kept against the address or number, so a contact created afterwards with the same email or phone is blocked as well.
  • A re-subscribe applies only to the contact it was made on. If one of the people re-subscribes, only that contact is reachable again; the others stay unsubscribed, because they may be different people. That contact keeps its own re-subscribe until any contact on the address or number unsubscribes again - then all of them are blocked again.
  • Saving the preference page unchanged changes nothing. Only a toggle the person actually changed is applied, and only to that contact.
  • A STOP reply can be overridden for one contact. An SMS, WhatsApp or Viber opt-out that came from a STOP keyword (from any provider, Delivyr included) can be re-subscribed by the API (a regular subscription update - no extra flag), an SDK, the contact page, the person's own preference page or forms, and an import only with "Override unsubscribes" ticked. It applies to that contact only - the other contacts on the number stay blocked - and a START reply still lifts it for the number as before. The contact's history records that the change overrode a STOP and who made it (the API key, the import job, the team member or the preference page).
  • SMS only: the provider may still block. Re-subscribing an SMS contact who texted STOP shows a warning wherever it happens (the contact page before saving, the import page and its summary, and the API response): This contact opted out by texting STOP. Your SMS provider may keep blocking messages until they text START. WhatsApp and Viber carry no such warning.
  • When the SMS provider still blocks. A send the SMS provider rejects because of its own STOP block (for example Twilio error 21610 or a Delivyr opted-out rejection) is shown as blocked by provider (opted out), not as a failure, and does not count toward bounce or failure-rate alerts. The opt-out is applied again, and the contact's history says provider still blocks: re-opted out.
  • Hard bounces and spam complaints are lifted only by Joryio support. A re-subscribe, the API or an import never clears them. Contact support when an address is confirmed to work again.
  • One workspace at a time. All of this happens inside the workspace where the opt-out was made; your other workspaces and brands are never touched.
  • Journeys follow the same rules as campaigns. An email list opt-out follows the address in journeys too: a journey email step that uses a subscription list skips every contact whose address left that list.

The subscriptions card on the contact page shows when a contact is blocked because of another contact with the same email or phone, why (unsubscribe, STOP, hard bounce or spam complaint), and when the contact has its own re-subscribe while the other contacts stay blocked. When no other contact in the workspace has the address, the card just says the contact re-subscribed after that email or phone number unsubscribed.

A store order or checkout never replaces a contact's existing phone (it only fills an empty one) - only the store's customer account data does, and in Magento an order by a signed-in customer. An SMS opt-in given with another number does not subscribe the contact's number. See how store data updates a contact for Shopify, WooCommerce and Magento.

The marketing consent a store sends follows the rules above - and these, the same for every store (PrestaShop's newsletter adds its own limits - see PrestaShop newsletter):

What the store sendsWhat Joryio does
An opt-in tick at a guest checkoutSubscribes a contact who has no subscription on that channel yet. Unless the store setting below is on, never re-subscribes someone who unsubscribed - anyone can type anyone's email at a guest checkout.
An opt-in by a logged-in customer (WooCommerce: an order placed while logged in, plugin 1.6.1 or later; Magento: an order by a signed-in customer), dated after the contact's latest unsubscribeAlso re-subscribes a contact who unsubscribed through an unsubscribe link or the one-click button, the preference page, the API, a team member, or this store's own opt-out.
Any opt-in from Shopify (setting off)Treated as a guest tick. Shopify attaches a guest checkout to the existing customer with that email and does not say whether the shopper was logged in, so a Shopify opt-in never re-subscribes someone who unsubscribed.
An opt-in at checkout or in the customer record, with the store's Let checkout opt-ins re-subscribe people who unsubscribed setting turned on (off by default; Shopify, WooCommerce, Magento and PrestaShop)Re-subscribes a contact who unsubscribed - including after a guest checkout - when the store's time for the opt-in is after their latest unsubscribe. The risk: a guest checkout cannot prove who the shopper is, so someone typing another person's email could re-subscribe them; they can always unsubscribe again with one click.
An unticked boxNo change - it is never an unsubscribe.
An opt-out in the storeUnsubscribes, unless Joryio recorded a later change for that contact.
Never undone by any store opt-in, setting on or offA spam complaint, a hard bounce, an organization-wide block, another workspace, an unsubscribe recorded on another contact with the same email or phone, and an SMS STOP.
  • The setting is per store connection, in Settings → Integrations → <store>, off by default; only admins can change it, and each change is recorded in the audit log. With email list double opt-in or SMS double opt-in on, the confirmation is sent first and the contact stays pending until they confirm.
  • A re-subscribe applies only to that contact; other contacts with the same email or phone stay unsubscribed. The contact's history says why, for example re-subscribed by the store checkout (logged-in customer) after unsubscribing via the preference page.
  • SMS: with SMS double opt-in on, a re-subscribe sends one "Reply YES" text and the contact stays pending until they reply. A contact who texted STOP comes back only this way - with SMS double opt-in off, a store opt-in never re-subscribes them.
  • Store consent subscribes the channel, not a list: a list that requires double opt-in still sends its confirmation email when the person joins that list.

PrestaShop consent is the customer's newsletter subscription, for email only. See also PrestaShop: Marketing consent.

  • The customer record. The newsletter flag subscribes the contact to email, dated by PrestaShop's newsletter_date_add. Unsubscribing in the store (the account page or the back office) is an explicit store opt-out and unsubscribes, unless Joryio recorded a later change for that contact. A change saved on the customer from the storefront by someone who is not signed in as that customer can unsubscribe, but never subscribes.
  • The newsletter block (ps_emailsubscription) is an anonymous form: anyone can type any email address. A block subscribe counts as consent only for the signed-in registered owner of that email, or after PrestaShop's email confirmation when the shop has verification e-mails turned on; any other block signup is not applied. A block unsubscribe is applied by email address.
  • The initial sync imports subscribers as opt-ins only: it never unsubscribes anyone and never re-subscribes a contact who unsubscribed.
  • After an unsubscribe, a PrestaShop newsletter opt-in does not re-subscribe the contact - the logged-in customer rule above does not apply to PrestaShop. The only exception is the newsletter box ticked when a guest checkout creates the shopper's guest account, with Let checkout opt-ins re-subscribe people who unsubscribed turned on.
  • Partner offers (optin) are not marketing consent and are ignored.

Details per store: Shopify, WooCommerce, Magento, PrestaShop.


Unsubscribing is per channel​

Consent is given per channel, so an unsubscribe affects only the channel it came from.

  • Email unsubscribe link and one-click header. The person stops getting email (and, when the campaign has a subscription topic, also leaves that topic on email). Their SMS, WhatsApp, push and Viber subscriptions are untouched. The page tells them: "You've been unsubscribed from emails. You'll still get SMS/WhatsApp messages you signed up for."
  • Unsubscribe from everything. After that, the page offers an explicit Unsubscribe from everything button. Only this button unsubscribes every channel and topic; it is recorded as an unsubscribe with scope global, attributed to the same campaign and variant.
  • SMS, WhatsApp and Viber. STOP and the unsubscribe link affect only that channel (or only the sender's list); STOPALL widens to every number on that channel, never to other channels.
  • Links sent before 2026-10-04. Unsubscribe links already in people's inboxes keep doing what they did when they were sent: an older email's link still unsubscribes from everything.
  • Preference page. Stays per channel and per topic.

Legal basis. Consent is collected per channel, and an unsubscribe withdraws it for that channel: from then on, no marketing on that channel. Other channels may be used only where the person is opted in to them.


List-Unsubscribe Header (RFC 8058)​

Overview​

RFC 8058 defines a standard way for email clients to provide one-click unsubscribe functionality. Joryio automatically adds these headers to outgoing emails.

On by default. The header is ON for every workspace, including a workspace that never saved its Subscription Settings - every marketing email (campaigns and journeys) carries it. It is removed only when you turn the List-Unsubscribe switch off in Subscription Settings, or when a transactional campaign sets its own No unsubscribe - transactional only override. Gmail and Yahoo require one-click unsubscribe from bulk senders, so leave it on for marketing email.

Headers Added​

List-Unsubscribe: <https://api-eu1.joryio.com/api/u/{token}>
List-Unsubscribe-Post: List-Unsubscribe=One-Click

Configuration​

Stored in workspace settings (a missing listUnsubscribe object, or one without enabled, means ON; only enabled: false turns it off):

subscriptionSettings: {
listUnsubscribe: {
enabled: true,
includeMailto: true, // Include mailto: link (recommended for Gmail)
scope: 'global' // 'global' or 'list'
}
}

Scope Options​

ScopeBehavior
globalOne-click unsubscribe removes user from all email communications
listOnly unsubscribes from the specific list the email was sent from

Campaign-Level Control​

For an email campaign, the composer's Compose step has a single Unsubscribe select that controls both whether the one-click unsubscribe link/header is present and what it removes people from:

  • Global unsubscribe (default) - the header is included and one-click unsubscribe removes the recipient from all email (a workspace-wide opt-out). This is the normal choice for marketing email.
  • Unsubscribe from: <topic> (shown only when the workspace has subscription lists) - the header is included and unsubscribe removes the recipient from that topic only, leaving their other topics intact. Selecting a topic also makes the send skip anyone who already unsubscribed from it.
  • No unsubscribe - transactional only - suppresses the List-Unsubscribe header entirely for this send (receipts, password resets, one-time codes), which are exempt from opt-out requirements.

Targeting vs. unsubscribe scope. This control is only about the unsubscribe link/header. It does not filter recipients. To send only to a topic's subscribers, add a "member of list" filter in the Audience step - that filter already excludes anyone who unsubscribed from the list and is evaluated at send time.

SMS / WhatsApp don't have an RFC 8058 header, so their Compose step instead shows an Unsubscribe topic select (Global vs. a specific topic) with the same scope meaning; there is no "no unsubscribe" option because a STOP opt-out is always honored.

At send time the campaign selection is merged over the workspace policy. The stored shape:

campaign.channelConfig = {
// omit listUnsubscribe entirely to inherit the workspace policy (= Global)
listUnsubscribe: {
enabled: false, // "No unsubscribe": suppress the header for this campaign
},
};
// Topic scope is stored separately as the campaign's subscriptionCategoryId.

Compliance: only choose "No unsubscribe" for genuine transactional / relationship messages. Bulk and marketing email require a List-Unsubscribe header (Gmail and Yahoo bulk-sender rules).


Hosted preference page and unsubscribe​

Every message carries a link to the hosted page: the unsubscribe link (/u/:token) and the manage-preferences link (/preferences/:token). This section explains what a recipient sees there.

Which channels appear​

The page lists only the channels that make sense for this person:

  • the channel is set up in your workspace (a provider or sender is configured for it), and
  • the person is reachable on it: an email address for Email; a phone number for SMS, WhatsApp and Viber; at least one active push device or web push subscription for Push.

A channel the person is currently subscribed to is always shown, even when it is not otherwise reachable, so they can always leave it. List rows follow the same rule. Viber appears with its own label like every other channel.

Unsubscribe link: one click on the page, and Undo​

Opening the unsubscribe link never changes anything by itself. The page asks first:

  1. It says what will stop and from whom - for example "You'll stop getting emails from Acme." - and the address the message was sent to.
  2. The person presses Unsubscribe. Only a real click or key press on that button sends the unsubscribe: the page never unsubscribes on its own, however long it stays open. An optional reason can be chosen just above the button.
  3. Change preferences instead opens the full channel and list controls instead of unsubscribing.

The preference center asks the same question. Its unsubscribe button (for example Unsubscribe from emails) does not unsubscribe by itself: it opens this question, with the optional reason, and only Unsubscribe there sends the unsubscribe. The reason is asked only on this question - saving preferences, turning off a channel or list, and re-subscribing never ask for one or send one.

The link's scope is the same as before: the channel the message came on, or one list; an older link that predates per-channel links still leaves everything.

After the unsubscribe, the top of the page says You're unsubscribed and what was removed, and offers:

  • Undo - shown for 10 minutes. It re-subscribes exactly what was just removed, through the same audited path as a preference save (recorded as undo_unsubscribe), and re-arms the link.
  • Manage preferences - reveals the channel and list controls below.

The result of every action (unsubscribe, unsubscribe from everything, save, re-subscribe) is shown at the top of the page and the page scrolls to it. This also works on a custom-designed page (the question and the controls are placed in your {{ preferences_form }} slot) and in redirect mode (the person presses Unsubscribe first, and the unsubscribe is recorded before they are sent to your URL).

From 2026-10-11 to 2026-10-15 the page unsubscribed on its own once it had been visible for about a second. It no longer does - see the next section.

Why the page asks for a click​

Why it matters. Many companies run corporate email security that opens every link in a message (often called "safe links" or link scanning) before the person has read it. Some of these scanners open links in a real browser, which to a server looks just like a person. A page that unsubscribed by itself once opened therefore unsubscribed people who never asked to leave - in one case 48 seconds after the send, right after the scanner had clicked every link in the email.

What Joryio does.

  • Opening the link changes nothing. A person has to press Unsubscribe on the page.
  • The button acts only on a real click or key press. A script running in the page cannot press it for the person.
  • Undo is offered for 10 minutes, so a person who left by mistake can come back in one click.
  • An unsubscribe that still looks automatic (it happened within seconds of the message being sent, or the traffic looks like a scanner) is recorded but flagged Possibly automatic in the contact's subscription history, so you can review it.

The mail app's own Unsubscribe button stays one click. Gmail, Yahoo and Apple Mail show their own Unsubscribe button using the one-click standard (RFC 8058, the List-Unsubscribe-Post header). This is required for bulk senders, is sent by the mailbox provider only when the person presses that button, and is not triggered by link scanners. It works exactly as before (see List-Unsubscribe Header). It uses a different address from the page's button, so the two are recorded apart.

Where unsubscribe reasons appear​

The question that asks whether to unsubscribe (on the unsubscribe link, and after the preference center's unsubscribe button) offers an optional reason. The reason, and the Possibly automatic flag, appear in three places:

  • Contact profile - the Subscription history card shows the reason on each unsubscribe and a Possibly automatic tag on flagged ones.
  • Campaign analytics - an Unsubscribes by reason card next to the unsubscribe count: one row per person by their latest reason, the top reasons plus "Others", and "No reason given". Unsubscribes recorded before October 2026 have no reason in analytics and count as "No reason given".
  • Settings > Subscriptions > Unsubscribe reasons - the workspace list of recent unsubscribes with a reason or a flag: when, contact (personal data masked for members without the pii:view permission), channel or list, reason and campaign, with a Possibly automatic only filter.

How each unsubscribe happened. Since 2026-10-15 every unsubscribe also records how it arrived - the mail app's unsubscribe button, the Unsubscribe button on the page, the preference center, unsubscribe from everything, the API, a team member in the dashboard, the SDK, an import, a STOP reply, a spam complaint, the email provider's own unsubscribe or a store sync - plus the browser (browser and operating system only, never the full string or the IP address) and how long after the send it came. The contact's Subscription history shows it on each unsubscribe as How. Unsubscribes the page made without a click between 2026-10-11 and 2026-10-15 show as "Unsubscribe page opened, no click".

FAQ: automatic unsubscribes​

Can a link scanner unsubscribe my contacts? Not from the page: opening the link changes nothing, and the page unsubscribes only when a person presses Unsubscribe with a real click or key press. Unsubscribes the page made on its own between 2026-10-11 and 2026-10-15 show as "Unsubscribe page opened, no click" in the contact's subscription history.

I see a sudden wave of unsubscribes. What should I do? Open Settings > Subscriptions > Unsubscribe reasons, turn on Possibly automatic only, and review the flagged entries and their campaign. Re-subscribe a person only with their own consent (for example they tell you it was a mistake, or they sign up again). Never re-subscribe people in bulk: an unsubscribe is a withdrawal of consent until the person says otherwise.

Does this change the Unsubscribe button in Gmail, Yahoo or Apple Mail? No. That one-click button is a separate, standard mechanism and is unchanged.

A person opens the same unsubscribe link again. What happens? The link keeps working until it expires. If they are still unsubscribed, the page confirms it and nothing is recorded again, so nothing is counted twice. If they subscribed again since (for example in the preference center), the link unsubscribes them again: clicking unsubscribe always works, with the same protection against scanners as the first time.


Preference Page (Custom Design)​

By default, recipients who click an unsubscribe or "manage preferences" link see Joryio's built-in preference center. You can replace it with your own branded page.

Where: Settings → Subscriptions → Preference page.

The page is per workspace and has three modes:

  • Built-in default - Joryio's standard preference center (channel + list toggles). Always works; no setup.
  • Custom HTML - a full HTML/Liquid editor with a live preview (rendered with sample data). Covered below.
  • Redirect to URL - skip our page entirely and send the recipient to your own. On the unsubscribe link we record the opt-out first, then redirect to your URL with ?email=…&status=unsubscribed appended; the manage-preferences link redirects with a ?token=… your page can use to read/write preferences via the public API. Compliance is preserved either way.

How it works​

  • You author the page body in HTML. It may use Liquid: personalization ({{ firstName }}, {{ email }}), content blocks ({{ blocks.<slug> }}), and the subscription URL tags below.
  • Place the {{ preferences_form }} tag where the functional subscription controls (channel + list toggles, Save, Unsubscribe from all) should appear - Joryio renders the working form into that spot.
  • If you omit the tag, the controls are appended at the end automatically, so a recipient can always unsubscribe (the editor warns you when the tag is missing).
  • The page is rendered server-side for each recipient (so the unsubscribe URLs and personalization are real), then sanitized before it reaches the browser: layout, images, inline styles and a scoped <style> block are kept; scripts, event handlers, iframes, <form> elements and script-bearing CSS are stripped.

Available tags​

TagDescription
{{ preferences_form }}The functional subscription controls (place once)
{{ unsubscribe_url }}Global unsubscribe
{{ preferences_url }}Link back to this page
{{ resubscribe_url }}Re-subscribe link
{{ firstName }} / {{ email }}Recipient personalization
{{ blocks.<slug> }}A reusable content block

Notes​

  • Full-page markup (<html>/<head>/<body>) is accepted; the page is a standalone screen, so its body content and styles are what render.
  • The same page serves both the one-click unsubscribe (/u/:token) and the manage-preferences (/preferences/:token) links.
  • The built-in default page also offers a "Re-subscribe to all" action; a {{ resubscribe_url }} (which appends ?action=resubscribe) re-subscribes on open. The optional "reason for unsubscribing" (recorded on the audit log + the message.unsubscribed event) is asked only on the unsubscribe question, never next to Save or Re-subscribe to all.

Bounce Handling​

Bounce Types​

TypeDescriptionAction
Soft BounceTemporary delivery failure (mailbox full, server down)Counted within a window; escalated to a hard bounce after too many
Hard BouncePermanent delivery failure (invalid address, domain doesn't exist)Mark the email invalid (suppressed from sending)

A hard bounce marks the address invalid and stops sending to it, but it does not unsubscribe the contact - they never asked to opt out, so their consent is left untouched (clearing the bounce, e.g. on email change, makes them sendable again). The suppression follows the email address across every duplicate contact in the workspace (see Email opt-outs follow the address), so a second contact on the same dead mailbox is not mailed either. A soft bounce is tolerated up to softBounceMaxRetries times within softBounceRetryHours; beyond that it is treated as a hard bounce.

Configuration​

subscriptionSettings: {
bounceHandling: {
softBounceRetryHours: 24, // Window in which soft bounces are counted
softBounceMaxRetries: 5, // Soft bounces in-window before escalating to a hard bounce
resubscribeOnEmailChange: true // Clear the invalid flag when the email changes
}
}

Webhook Integration​

Joryio automatically processes bounce webhooks from:

  • Amazon SES: SNS notifications
  • SendGrid: Event webhooks
  • Mailgun: Webhooks

Provider bounce/event webhooks are configured with your email provider during onboarding - events then flow in automatically.

Manual Bounce Clearing​

Administrators can clear bounce status through the UI or API:

await subscriptionsApi.clearBounceStatus(userId, 'Email confirmed valid by user');
Hard bounces and spam complaints

Clearing a bounce from the dashboard or the API clears a soft bounce only. A hard bounce or a spam complaint is lifted only by Joryio support - see Contacts that share an email or phone.

Sign in with Apple relay addresses

If mail to @privaterelay.appleid.com or @private.icloud.com addresses bounced because your sending domain was not registered with Apple, those addresses may be marked invalid. Fix the registration first, then clear the bounces. See Apple Private Email Relay.


Liquid Template Variables​

Available Variables​

Use these in your email templates:

VariableDescription
{{ unsubscribe_url }}Global unsubscribe URL
{{ unsubscribe_url_list }}List-specific unsubscribe URL
{{ preferences_url }}Preference center URL
{{ resubscribe_url }}Re-subscribe URL (for win-back campaigns)

Example Usage​

<p>Don't want to receive these emails?</p>
<p>
<a href="{{ unsubscribe_url_list }}">Unsubscribe from this list</a>
or
<a href="{{ preferences_url }}">Manage your preferences</a>
</p>

Segment Filters​

Filter Types​

Three new filter types for segmentation:

Channel Subscription Filter​

Target users based on channel subscription status:

{
type: 'channel_subscription',
operator: 'subscribed_to_channel', // or 'not_subscribed_to_channel'
channel: 'email',
subscriptionStatus: 'opted_in' // optional: specific status
}

List Membership Filter​

Target users based on list membership:

{
type: 'list_membership',
operator: 'member_of_list', // or 'not_member_of_list'
listId: 'list-uuid',
channel: 'email' // optional: specific channel
}

Bounce Status Filter​

Target users based on email validity:

{
type: 'bounce_status',
operator: 'email_valid' // 'email_valid', 'email_bounced',
// 'email_hard_bounced', 'email_soft_bounced'
}

Use Cases​

  1. Send to opted-in users only:

    { type: 'channel_subscription', operator: 'subscribed_to_channel',
    channel: 'email', subscriptionStatus: 'opted_in' }
  2. Exclude bounced emails:

    { type: 'bounce_status', operator: 'email_valid' }
  3. Target newsletter subscribers:

    { type: 'list_membership', operator: 'member_of_list',
    listId: 'newsletter-list-id' }

API Reference​

The full REST reference for managing channel consent, subscription lists, and list membership - every endpoint with request/response examples and required scopes - lives on the Subscriptions API page.

Two things worth knowing before you call it:

  • You can subscribe in the same call that creates the contact. Each attributes element of POST /users/track takes subscriptions - see List subscriptions. The dedicated endpoints below remain the way to change consent later. An existing opt-out is never resurrected by that call or by an import: it is kept and reported, and re-opting-in an unsubscribed contact always requires an explicit call to the subscribe endpoint.
  • The :userId in these routes is Joryio's internal contact id (as returned by the Users API), not your external user id.

A typical opt-in - subscribe a contact to a list on the email channel:

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" }'

Public Endpoints (No Auth)​

// One-click unsubscribe (RFC 8058): the mail app's List-Unsubscribe-Post
POST /u/:token

// The hosted page's Unsubscribe button (a person's click only, see #automatic-unsubscribe)
// Body: { reason?: string, view?: 'unsubscribe' | 'preferences' }
POST /u/:token/confirm

// Get preferences
GET /preferences/:token

// Save preferences
POST /preferences/:token
Body: { channels: [...], lists: [...], reason?: string, undo?: boolean }

SDK Endpoints (SDK Key Auth)​

These endpoints are used by the Web SDK and authenticated via the X-SDK-Key header.

// Update channel subscription
POST /v1/subscriptions/channel
Headers: { 'X-SDK-Key': 'jry_sdk_web_...' }
Body: {
channel: 'email' | 'sms' | 'whatsapp' | 'push' | 'viber',
status: 'optedIn' | 'subscribed' | 'unsubscribed',
userId?: string, // Use if user is identified
anonymousId?: string // Use if user is anonymous
}

// Update subscription group (list) membership
POST /v1/subscriptions/group
Headers: { 'X-SDK-Key': 'jry_sdk_web_...' }
Body: {
groupId: string, // List ID
channel: 'email' | 'sms' | 'whatsapp' | 'push' | 'viber',
action: 'subscribe' | 'unsubscribe',
userId?: string,
anonymousId?: string
}

If there is no profile yet for the userId or anonymousId, the server creates it - by userId when one is sent, otherwise as an anonymous profile - the same way /v1/alias/add does, so a consent change made before the first event is stored is not lost. Older servers answered 404 instead. Status codes for these routes are listed under Other write responses.


Configuration​

Workspace Settings​

Configure subscription settings in Settings > Subscriptions:

interface SubscriptionSettings {
listUnsubscribe?: {
enabled: boolean;
includeMailto: boolean;
scope: 'global' | 'list';
};
bounceHandling?: {
softBounceRetryHours: number; // Window for counting soft bounces
softBounceMaxRetries?: number; // Soft bounces in-window before escalating to hard
resubscribeOnEmailChange: boolean; // Clear the invalid flag when the email changes
};
doubleOptIn?: {
defaultEnabled: boolean;
confirmationEmailTemplateId?: string;
};
bccEmail?: string; // For compliance archiving
}

Compliance​

So a marketing email can never go out with no way to opt out:

  • Compliance footer block - a compliance_footer content block (auto-created with a sensible default the first time you enable auto-append) holds your address + unsubscribe/preferences links. Reference it anywhere with {{ blocks.compliance_footer }}, or edit it under Content Blocks.
  • Auto-append toggle - Settings → Subscriptions → "Auto-append an unsubscribe footer when missing". When on, the email send path appends the compliance footer to any marketing email (one carrying a List-Unsubscribe header) whose body has no unsubscribe/preferences link. Fail-open: it never blocks a send.
  • Composer warning - the campaign email composer shows a warning when the email body has no unsubscribe link, so you catch it before sending (a visible backstop, not a hard block). It offers two inline shortcuts - Add a link (opens the editor to insert one) and enable auto-append footer (jumps to this Subscriptions page) - and can be dismissed for the session. When auto-append is already on it becomes a calmer info note instead, since the footer is added for you.

Subscription lists can also be selected on SMS, WhatsApp, and Viber journey nodes (not just email), so the per-node send-path gate skips contacts who unsubscribed from that list on any channel.

GDPR (Europe)​

  • Use double opt-in for EU users
  • Store consent text and timestamp
  • Provide easy access to preference center
  • Honor unsubscribe requests immediately
  • Maintain audit log of all subscription changes

CAN-SPAM (United States)​

  • Include physical mailing address in emails
  • Honor unsubscribe requests within 10 business days (Joryio does this immediately)
  • Clear identification of commercial emails
  • No deceptive subject lines

TCPA (United States - SMS)​

  • Obtain express written consent before sending SMS
  • Honor STOP keywords immediately
  • Provide opt-out instructions in messages
  • Joryio automatically handles: STOP, STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT, REVOKE, OPTOUT, OPT OUT, OPT-OUT (the always-on English floor), plus localized defaults (e.g. Hebrew הסר) and any custom keywords you add

SMS & WhatsApp Keyword Handling​

Joryio automatically processes inbound keywords, matching them case- and punctuation-tolerantly (Stop, STOP!, " stop " all count). STOPALL is the only built-in channel-wide opt-out (plus any custom channel-wide opt-out keywords you configure); every other opt-out keyword scopes to the per-number / per-WABA subscription group the message arrived at, and START re-subscribes that same number's/WABA's group. For the full set - English floor (incl. REVOKE / OPTOUT), localized defaults, and your custom additions - and the re-subscribe rule, see Keywords above.

SMS

KeywordAction
STOP, UNSUBSCRIBE, CANCEL, END, QUIT, REVOKE, OPTOUT, OPT OUT, OPT-OUTOpt out of the receiving number's group
STOPALLOpt out channel-wide (every number)
START, YES, UNSTOP, SUBSCRIBE, OPTINRe-subscribe to the receiving number's group
HELP, INFOSend help message
Localized defaults (e.g. הסר, PARE, ARRET) + custom additionsSame as their category above

WhatsApp

KeywordAction
STOP, UNSUBSCRIBE, CANCEL, END, QUIT, OPTOUT, OPT-OUTOpt out of the receiving WABA's group
STOPALLOpt out channel-wide (every WABA)
START, UNSTOP, SUBSCRIBE, YES, OPTIN, OPT-INRe-subscribe to the receiving WABA's group

Inbound webhooks are signature-verified - forged STOP / START are rejected, and providers that don't support inbound signature verification are rejected for inbound keywords. If the per-number / per-WABA group can't be resolved, the opt-out fails safe to channel-wide.

The inbound SMS webhook is configured on your numbers during onboarding - keywords are processed automatically.


Best Practices​

  1. Always use List-Unsubscribe headers - Improves deliverability and Gmail displays unsubscribe button

  2. Implement double opt-in for marketing - Better list quality and compliance

  3. Monitor bounce rates - High bounce rates hurt sender reputation

  4. Segment by subscription status - Only send to opted-in users

  5. Make unsubscribe easy - One click, no login required

  6. Keep audit logs - Required for compliance, helpful for disputes

  7. Test preference center - Ensure users can manage their preferences easily

  8. Use list-specific unsubscribe - Allows users to stay subscribed to some content


Troubleshooting​

Common Issues​

Emails not being sent

  • Check if user has unsubscribed status for email channel
  • Check if email is marked as invalid due to bounces
  • Verify bounce status in user profile

Unsubscribe not working

  • Check token expiration (default 90 days)
  • Verify token signature is correct
  • Check audit log for errors

Bounce not being processed

  • Verify webhook URL is configured correctly in email provider
  • Check webhook authentication
  • Review error logs

Debugging​

Check subscription status:

# API call
curl -X GET "https://api-eu1.joryio.com/api/subscriptions/contacts/{userId}" \
-H "Authorization: Bearer {token}"

View audit log:

curl -X GET "https://api-eu1.joryio.com/api/subscriptions/contacts/{userId}/history" \
-H "Authorization: Bearer {token}"