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 - 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 fresh opt-in clears 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. It is lifted only by an explicit operator action: DELETE /suppressions/{identifier}, a dashboard "clear bounce", 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 jane@example.com once and all contacts with that address are gated on email, workspace-wide.


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/suppressions?channel=email&reason=hard_bounce&limit=50" \
-H "Authorization: Bearer jry_live_your_api_key"

Response

{
"items": [
{
"identifier": "dead-address@example.com",
"channel": "email",
"identifierType": "email",
"reason": "hard_bounce",
"source": "delivery",
"scope": "global",
"listId": null,
"createdAt": "2026-06-30T12:04:11.000Z"
},
{
"identifier": "jane@example.com",
"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/suppressions/dead-address@example.com?channel=email" \
-H "Authorization: Bearer jry_live_your_api_key"

Response - suppressed

{
"suppressed": true,
"identifier": "dead-address@example.com",
"reason": "hard_bounce",
"source": "delivery",
"scope": "global",
"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.
scopestringNoglobal (default) or group.
listIdstringNoRequired when scope is group: the group/list this suppression applies to.

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/suppressions \
-H "Authorization: Bearer jry_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"channel": "email",
"identifier": "jane@example.com",
"reason": "unsubscribe",
"scope": "group",
"listId": "grp_newsletter"
}'

Response

{
"identifier": "jane@example.com",
"channel": "email",
"identifierType": "email",
"reason": "unsubscribe",
"source": "api",
"scope": "group",
"listId": "grp_newsletter",
"createdAt": "2026-07-11T08:15:00.000Z"
}

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/suppressions/hard-bounce \
-H "Authorization: Bearer jry_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"identifier": "no-such-mailbox@example.com"
}'

Response

{
"identifier": "no-such-mailbox@example.com",
"channel": "email",
"identifierType": "email",
"reason": "hard_bounce",
"source": "api",
"scope": "global",
"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/suppressions/import \
-H "Authorization: Bearer jry_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"channel": "email",
"entries": [
{ "identifier": "old-dead-1@example.com" },
{ "identifier": "opted-out@example.com", "reason": "unsubscribe" },
{ "identifier": "old-dead-2@example.com" }
]
}'

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 all suppression rows for the identifier on the channel in this workspace - including a hard_bounce or complaint deliverability row. That is a deliberate operator/API lift: unsuppressing an address is exactly how you clear a resolved hard bounce. Only lift a deliverability suppression when you know the underlying problem is fixed, or you risk sending to a dead address and hurting your sender reputation.

Example request

curl -X DELETE "https://api-eu1.joryio.com/suppressions/no-such-mailbox@example.com?channel=email" \
-H "Authorization: Bearer jry_live_your_api_key"

Response

{
"identifier": "no-such-mailbox@example.com",
"removed": 2
}

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


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