Skip to main content

Suppressions API

The Suppressions API manages the per-workspace lists of email addresses and phone numbers that Joryio will not send to. A suppression is a hard gate: while an identifier is suppressed on a channel, every message to it on that channel is skipped, regardless of which campaign or journey tries to reach it.

This API mirrors the Audience → Suppression Lists dashboard surface. Use it to audit who is suppressed, migrate an existing dead-address list from another platform, honor a consent request from your own systems, or lift a suppression after a hard-bounce is resolved.

All endpoints on this page are relative to the base URL: https://api-eu1.joryio.com/api - see API Overview.

Authentication​

Every request is authenticated with either an API key carrying the relevant deliverability scope, or a dashboard session (JWT). Suppression data is workspace-scoped - a key only ever sees and edits its own workspace's lists.

Authorization: Bearer jry_live_your_api_key_here
Content-Type: application/json

Scopes per endpoint​

The channel decides which scope is required. Email endpoints need the email_suppression:* scope; SMS and WhatsApp endpoints need the sms_suppression:* scope.

EndpointEmail channelSMS / WhatsApp channel
GET /suppressionsemail_suppression:readsms_suppression:read
GET /suppressions/{identifier}email_suppression:readsms_suppression:read
POST /suppressionsemail_suppression:writesms_suppression:write
POST /suppressions/hard-bounceemail_suppression:write- (email only)
POST /suppressions/importemail_suppression:writesms_suppression:write
DELETE /suppressions/{identifier}email_suppression:writesms_suppression:write

Core concepts​

Read this section before you call the write endpoints - the rest of the API only makes sense once the reason vs source split is clear.

reason (why) vs source (where it came from)​

Every suppression row carries two independent fields:

  • reason - why the identifier is suppressed. One of unsubscribe, hard_bounce, complaint, manual.
  • source - where the suppression came from (its provenance). One of delivery, api, import, or a consent source such as unsubscribe_link.

These are orthogonal. The same reason can arrive from different sources - a hard_bounce observed by our own send pipeline has source: "delivery", while a hard_bounce you assert through this API has source: "api". The reason tells you the deliverability/consent meaning; the source tells you how much to trust it and whether it counts toward your reputation metrics.

The four reasons split into two families that behave differently:

FamilyReasonsMeaningSurvives a re-subscribe?
Consentunsubscribe, manualThe recipient (or you, on their behalf) asked not to be contacted.No - a re-subscribe lets that one contact through (other contacts with the address stay blocked); DELETE /suppressions lifts it.
Deliverabilityhard_bounce, complaintThe address/number is dead or flagged us as spam.Yes - it persists even if the recipient re-subscribes.

A deliverability suppression is a technical fact about the address, not a preference, so a recipient re-subscribing does not lift it - and neither do the API, an import or the dashboard. Only Joryio support lifts a hard bounce or a spam complaint (or the contact changing to a new email address).

You cannot fabricate a bounce​

The ordinary write paths - POST /suppressions and POST /suppressions/import - clamp reason to manual or unsubscribe. They physically cannot create a hard_bounce or complaint row. A bounce is something the platform observes, not something a client asserts casually.

The one path that may assert a bounce is POST /suppressions/hard-bounce, and even then the row is stamped source: "api" so it is never confused with a bounce we saw ourselves.

Only real bounces touch your reputation​

Your account's reported bounce rate and deliverability/reputation metrics count only source: "delivery" bounces - the ones our own send pipeline observed at the SMTP layer. An API-asserted (source: "api") or imported (source: "import") suppression blocks sending to that identifier but does not inflate your reported bounce rate. This lets you protect your sender reputation by pre-loading known-dead addresses without polluting the very metric you are trying to protect.

Suppression follows the address / number​

A suppression is keyed on the normalized email address or phone number, per workspace - not on a contact record. One suppressed address therefore covers every duplicate contact that shares it. Suppress [email protected] once and all contacts with that address are gated on email, workspace-wide.

Phone numbers are read the way contacts store them: a number without a country code (0501234567) is read in the workspace's default country for phone numbers, and the suppression is stored both under that number (+972501234567) and under the older form older records may carry, so it blocks every contact with the number. A number that is not valid at all is still stored - it is never dropped.

An ambiguous number (valid both as a national number of the default country and as another country's number) is refused (400) when you add or remove it, and skipped (counted in ambiguous) in a bulk import, because one of the readings may be someone else's number: send it with the country code (+...).


Reason / source reference​

reason values​

reasonFamilyCreated by
unsubscribeConsentRecipient opt-out link, POST /suppressions, POST /suppressions/import
manualConsentOperator action, POST /suppressions, POST /suppressions/import
hard_bounceDeliverabilityOur send pipeline, POST /suppressions/hard-bounce
complaintDeliverabilityOur send pipeline (feedback loops)

source values​

sourceMeaningCounts toward reputation?
deliveryObserved by our own send/receive pipeline (real bounce or complaint).Yes
apiAsserted through this REST API.No
importLoaded via POST /suppressions/import (bulk migration).No
unsubscribe_link (and other consent sources)Recipient-driven consent change.No

List suppressions​

Return a paginated list of suppressed identifiers on a channel.

Endpoint​

GET /suppressions

Query parameters​

ParameterTypeDefaultDescription
channelstring-email, sms, or whatsapp. Required.
reasonstring-Optional filter: unsubscribe, hard_bounce, complaint, or manual.
limitnumber100Rows per page (max 1000).
offsetnumber0Pagination offset.

Example request​

curl -X GET "https://api-eu1.joryio.com/api/suppressions?channel=email&reason=hard_bounce&limit=50" \
-H "Authorization: Bearer jry_live_your_api_key"

Response​

{
"items": [
{
"identifier": "[email protected]",
"channel": "email",
"identifierType": "email",
"reason": "hard_bounce",
"source": "delivery",
"scope": "channel",
"listId": null,
"createdAt": "2026-06-30T12:04:11.000Z"
},
{
"identifier": "[email protected]",
"channel": "email",
"identifierType": "email",
"reason": "unsubscribe",
"source": "unsubscribe_link",
"scope": "group",
"listId": "grp_newsletter",
"createdAt": "2026-07-02T09:20:00.000Z"
}
],
"total": 214,
"limit": 50,
"offset": 0
}

Check one identifier​

Check whether a single identifier is suppressed on a channel.

Endpoint​

GET /suppressions/{identifier}

The path parameter {identifier} is the URL-encoded email address or phone number.

Query parameters​

ParameterTypeDefaultDescription
channelstring-email, sms, or whatsapp. Required.

Example request​

curl -X GET "https://api-eu1.joryio.com/api/suppressions/[email protected]?channel=email" \
-H "Authorization: Bearer jry_live_your_api_key"

Response - suppressed​

{
"suppressed": true,
"identifier": "[email protected]",
"reason": "hard_bounce",
"source": "delivery",
"scope": "channel",
"listId": null,
"createdAt": "2026-06-30T12:04:11.000Z"
}

Response - not suppressed​

{
"suppressed": false
}

Add a suppression​

Add a consent or manual suppression. Use this to honor an opt-out that reached you through your own systems (a support ticket, a CRM flag, a preference-center change on your side).

Endpoint​

POST /suppressions

Request body​

FieldTypeRequiredDescription
channelstringYesemail, sms, or whatsapp.
identifierstringYesEmail address or phone number to suppress.
reasonstringNomanual (default) or unsubscribe. Clamped - any other value is rejected.
scopestringNochannel (default) - every list on the channel - or group with a listId for one list.
Why there is no org-wide scope here

Every credential on this endpoint carries a single workspace: an API key stores one workspaceId, and a dashboard session acts inside its current workspace. An org-wide suppression - one that silences an identifier for every workspace in the organisation - has no caller here who represents the organisation, so it cannot be created through this API.

It used to be the DEFAULT, which meant a single-brand integration silently suppressed an identifier for sibling brands by omission.

Org-wide rows created before this change are still read and still honoured when sending. A DELETE clears this workspace's rows only - never an org-wide row, because that would lift the block for every other workspace too. To lift a legacy org-wide row, contact Joryio support.

| listId | string | No | Required when scope is group: the group/list this suppression applies to. It must be a list in this workspace - a missing listId, or another workspace's, returns 400. |

The source of a row created here is always recorded as api. This endpoint cannot create a hard_bounce or complaint.

Example request​

curl -X POST https://api-eu1.joryio.com/api/suppressions \
-H "Authorization: Bearer jry_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"channel": "email",
"identifier": "[email protected]",
"reason": "unsubscribe",
"scope": "group",
"listId": "grp_newsletter"
}'

Response​

{
"identifier": "[email protected]",
"channel": "email",
"identifierType": "email",
"reason": "unsubscribe",
"source": "api",
"scope": "group",
"listId": "grp_newsletter",
"createdAt": "2026-07-11T08:15:00.000Z"
}
reason: "unsubscribe" updates the contacts too

With reason: "unsubscribe", every contact in the workspace with that email or phone is also set to unsubscribed on the channel (or, with scope: "group", on that list), with a history entry. A manual suppression blocks sending without changing the contacts' own status.


Assert a hard bounce​

Record a hard bounce for an email address. This is the only API path that may assert a deliverability suppression, and it is deliberately separate and constrained.

Why this endpoint is separate​

  • A bounce is normally something Joryio observes at send time, not something a caller declares. Keeping bounce assertion off the ordinary add/import paths prevents accidental or careless fabrication.
  • Because you are asserting rather than us observing, the row is stamped source: "api". It gates sending exactly like a real bounce but is never mistaken for one we saw ourselves, and it does not count toward your reported bounce rate or sender-reputation metrics.
  • It is email only. There is no phone equivalent - SMS/WhatsApp non-delivery is modeled differently.

Endpoint​

POST /suppressions/hard-bounce

Request body​

FieldTypeRequiredDescription
identifierstringYesThe email address that hard-bounced.

Channel is implicitly email. The row is recorded with reason: "hard_bounce" and source: "api".

Example request​

curl -X POST https://api-eu1.joryio.com/api/suppressions/hard-bounce \
-H "Authorization: Bearer jry_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"identifier": "[email protected]"
}'

Response​

{
"identifier": "[email protected]",
"channel": "email",
"identifierType": "email",
"reason": "hard_bounce",
"source": "api",
"scope": "channel",
"listId": null,
"createdAt": "2026-07-11T08:20:00.000Z"
}

Bulk import​

Load an existing suppression list - for example, when migrating off another platform.

Endpoint​

POST /suppressions/import

Request body​

FieldTypeRequiredDescription
channelstringYesemail, sms, or whatsapp.
entriesarrayYesUp to 5000 objects, each { identifier, reason? }.

Each entry's reason is clamped to manual (default) or unsubscribe; any other value is coerced to manual. Every imported row is recorded with source: "import". Like the add endpoint, import cannot create a bounce or complaint.

Example request​

curl -X POST https://api-eu1.joryio.com/api/suppressions/import \
-H "Authorization: Bearer jry_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"channel": "email",
"entries": [
{ "identifier": "[email protected]" },
{ "identifier": "[email protected]", "reason": "unsubscribe" },
{ "identifier": "[email protected]" }
]
}'

Response​

{
"channel": "email",
"received": 3,
"imported": 3,
"skipped": 0,
"source": "import"
}

Remove a suppression (unsuppress)​

Remove a suppression so Joryio may send to the identifier again.

Endpoint​

DELETE /suppressions/{identifier}

Query parameters​

ParameterTypeDefaultDescription
channelstring-email, sms, or whatsapp. Required.
warning

This removes the consent suppression rows (unsubscribe, manual) for the identifier on the channel in this workspace only. It never removes a STOP-keyword opt-out or a hard_bounce / complaint row - those are reported in kept. Contacts whose own status was set to unsubscribed stay unsubscribed; re-subscribe a contact through the Subscriptions API.

Example request​

curl -X DELETE "https://api-eu1.joryio.com/api/suppressions/[email protected]?channel=email" \
-H "Authorization: Bearer jry_live_your_api_key"

Response​

{
"identifier": "[email protected]",
"removed": 1,
"kept": { "deliverability": 1 }
}

removed is the number of suppression rows deleted (a single identifier can carry a group-scoped consent row and a global deliverability row at once).

kept (present only when something was kept) counts the rows this API may not remove: stop_keyword (removing it would lift the number for every contact that shares it - re-subscribe the one contact through the Subscriptions API instead, or the person texts START) and deliverability (a hard bounce or spam complaint - only Joryio support lifts it).

legacy_twin (phone only) counts entries under the number's old local form (+10612345678 for +31612345678) that were kept: they are lifted only when this workspace has a default country for phone numbers that reads them as exactly this number, because the same local digits can be another country's number. A START follows the same rule, and never lifts such an entry on an organization-wide opt-out.


Migrating an existing suppression list​

When you move to Joryio from another email or SMS platform, bring your suppression list with you on day one so your first send does not re-mail addresses you already know are dead or opted-out.

  1. Import the whole list via POST /suppressions/import. Entries arrive as manual (or unsubscribe if you tag them), with source: "import". They block sending and protect your sender reputation on the first send - without inflating your reported bounce rate, because imported rows never count toward reputation.
  2. Only if you specifically need those addresses to report as bounces - for example to keep your bounce analytics continuous across the migration - assert them individually with POST /suppressions/hard-bounce. They will still be stamped source: "api", so they gate sending and show as bounces in your suppression list without being counted as bounces we observed.

For most migrations, step 1 alone is the right call: it stops the sends and keeps your reputation metrics clean.


Error responses​

All errors share the standard shape - there is no separate machine-readable error-code vocabulary; use the HTTP status plus the message field. See Error Response in the API Overview. Validation failures (400) add an errors array with one message per failed field:

{
"statusCode": 400,
"message": "Bad Request Exception",
"timestamp": "2026-07-12T09:00:00.000Z",
"path": "/suppressions",
"errors": [
"reason must be one of the following values: manual, unsubscribe"
]
}

Notable: this API enforces channel-precise scopes. An API key that holds only the SMS scope gets a 403 when it touches email suppressions (and vice versa):

{
"statusCode": 403,
"message": "API key missing required scope 'email_suppression:write' for channel 'email'",
"timestamp": "2026-07-12T09:00:00.000Z",
"path": "/suppressions"
}

Next steps​