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. Per-number subscription groups (SMS & WhatsApp)
  7. List-Unsubscribe Header (RFC 8058)
  8. Preference Page (Custom Design)
  9. Bounce Handling
  10. Liquid Template Variables
  11. Segment Filters
  12. API Reference
  13. Configuration
  14. 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

Updating Channel Status

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

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://api-eu1.joryio.com/sdk/web/latest/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, subscription changes require an identified user. An anonymousId is client-generated and cannot be cryptographically bound to your signed token, so once you turn on SDK Authentication (opting into token-bound writes) an anonymous-only subscription change is rejected - call identify(userId) first so the change is token-bound. With SDK Authentication off, anonymous opt-in/opt-out is accepted as before.

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

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-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.

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) → must text START. When a contact opts out by SMS reply, the carrier (e.g. Twilio) places a carrier-level block on that number. There is no API to clear it - the only way back is for the contact to text START (or another opt-in keyword) from that same phone. Joryio never force-resubscribes someone who texted STOP; re-subscription is always contact-initiated.
  • 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.
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. A deliverability suppression is not lifted by a later resubscribe (only a genuine email change / manual clear restores it) - this protects your sender reputation.
  • 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.

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


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.

Headers Added

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

Configuration

Enable in workspace settings:

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).


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 an optional "reason for unsubscribing" (recorded on the audit log + the message.unsubscribed event) and a "Re-subscribe to all" action; a {{ resubscribe_url }} (which appends ?action=resubscribe) re-subscribes on open.

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');

Liquid Template Variables

Available Variables

Use these in your email templates:

VariableDescription
{{ unsubscribe_url }}Global unsubscribe URL
{{ unsubscribe_url_list }}List-specific unsubscribe URL
{{ category_unsubscribe_url }}Unsubscribe from the email's subscription list (alias of the list URL; falls back to global)
{{ 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 at creation time. POST /users accepts an optional subscriptions array applied in the same call - see the Users API. Create-time subscriptions never resurrect an existing opt-out; re-opting-in an unsubscribed contact still requires the dedicated subscribe endpoint below.
  • 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/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)
POST /u/:token

// Get preferences
GET /preferences/:token

// Save preferences
POST /preferences/:token
Body: { channels: [...], lists: [...] }

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
}

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 (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, OPTOUTOpt 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/subscriptions/contacts/{userId}" \
-H "Authorization: Bearer {token}"

View audit log:

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