Journeys suppressions from your app
A Host app can read its organisation's Journeys suppression list: every address Journeys must not mail, and why. A CRM uses it to keep its own opt-out flags in step with Journeys. An app can also add addresses to the list, so Journeys stops mailing people who opted out in the app.
The read is a feed. The first call with no cursor returns the whole list from the start (the backfill). After that, the app polls with the cursor it was given and gets only what changed.
An app can only suppress. It cannot lift a suppression, so it can never make Journeys mail someone again. That stays a person's choice: they opt back in through a Journeys form, or an admin reinstates them in Journeys.
The grants
Each is granted by name: journeys:suppressions:read to read the list, journeys:suppressions:write to add
to it. Neither includes the other. Only an org admin can add either.
waymaker host apps update <app-id> --commander-permissions "team_ids,journeys:suppressions:read"
From an agent, use the MCP tool host_app_update with
commander_permissions: ["team_ids", "journeys:suppressions:read"].
- The list replaces what the app had, so include the names it already holds.
*does not include them. They are only ever granted by name.- Removing one does not need an admin. The next call after removal is refused.
Calling it
The app calls its context API with POST https://apps.waymakerone.com/functions/v1/host-context-api,
authenticated as in reading a shared inbox: the app's
context key, or a signed-in person's token with X-App-Id. A person's token
works only while they are an active member of the organisation.
{ "action": "journeys_suppressions", "data": { "after": "<cursor, optional>", "limit": 200 } }
after: thenextfrom your previous call. Leave it out to start from the beginning.limit: 1 to 500. The default is 200.
It returns { suppressions, next, has_more, settle_seconds }.
nextis where to resume. It is returned even when the page is empty: keep it and poll with it.has_moreistruewhen there is another page ready now. Call again straight away.settle_secondsis 60. A change appears in the feed once it is a minute old, so a change still being written never lands behind a cursor you already hold.
The feed keeps your records in step; it is not a check to run before you send. It is always at least a minute behind, and Journeys already refuses to mail a suppressed address.
Re-read from the start now and then (say, once a day): call with no after and work through to the
end, skipping every event_id you have already applied. In rare cases a change that took a long time to
save can land just behind a cursor already handed out; a periodic full read catches it.
Backfill, then poll
const call = (data) => fetch('https://apps.waymakerone.com/functions/v1/host-context-api', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.WAYMAKER_CONTEXT_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'journeys_suppressions', data }),
}).then(async (r) => { if (!r.ok) throw new Error(`${r.status} ${(await r.json()).code ?? ''}`); return r.json() })
// The cursor you saved last time, or undefined for the first run.
let after = await loadCursor()
for (;;) {
const page = await call({ after, limit: 500 })
for (const s of page.suppressions) {
if (await alreadyApplied(s.event_id)) continue // event_id is the idempotency key
await applyToContact(s.email, s.outcome, s.reason)
}
after = page.next
await saveCursor(after) // save after applying, so a crash re-reads rather than skips
if (!page.has_more) break
}
// Then run the loop again on a schedule, every few minutes, starting from the saved cursor.
The fields
One entry per address, in its latest state. When a suppressed address is reinstated, or a
reinstated one is suppressed again, it comes round again later in the feed with its new state and a
new event_id.
| Field | |
|---|---|
event_id | Unique per change. Use it to skip a change you have already applied. |
id | The suppression's id. It stays the same across changes, so it is not an idempotency key. |
email | The address, lowercased. |
outcome | What happened. See below. |
occurred_at | When it happened, from the provider where there is one. This is not the feed order. |
reason | Why it was suppressed: unsubscribe, bounce_hard, bounce_soft, complaint or manual. On reinstated, the reason it had been suppressed for. |
note | The note an app gave when it suppressed the address, or null. |
source | journeys, app:<app id> for an app's write, or null for an entry recorded before sources were kept. Skip your own app's writes by this. |
journey_id | The campaign the address was suppressed from, or null when there was none or it was not recorded. |
workspace_id | The workspace, or null. |
Outcomes
outcome | Meaning |
|---|---|
unsubscribed | The person used an unsubscribe link. |
hard_bounced | Mail to the address bounced permanently. |
soft_bounce_suppressed | Mail kept bouncing temporarily until Journeys stopped sending. One soft bounce is not a suppression. |
complained | The person marked a message as spam. |
suppressed | Suppressed by an app or by hand, or for a reason this list adds later (reason says which). |
reinstated | The address may be mailed again: the person opted back in, or an admin lifted it. reason says what was lifted. |
What the feed does not carry
- The campaign for older entries.
journey_idwas not recorded before November 2026, so it isnullon those entries. - A bounce that has not led to a suppression. Journeys counts bounces itself and suppresses only past its threshold.
- Opens and clicks. They are not consent.
Errors
| Status | When |
|---|---|
403 | code: "missing_grant", grant: "journeys:suppressions:read": the app does not hold the grant. Ask an org admin to add it. Also when a person's token is used and they are not an active member. |
400 | after is not a cursor this API returned, or limit is not a positive whole number. |
401 | The app could not be authenticated. |
Suppressing addresses
{ "action": "journeys_suppress", "data": { "addresses": [{ "email": "a@example.com", "note": "asked on the phone" }], "note": "opted out in the CRM" } }
- The app's own context key only. A person's token, or the older
X-API-Key, is refused. - 1 to 500 addresses a call. Each needs a note saying why, its own or the call's, up to 200 characters.
- At most 5,000 addresses per app in any hour. A call that would go over is refused whole, with nothing written, and says when to retry. Send the same batch again then.
- Each address is lowercased and recorded with
reason: manualandsource: app:<your app id>, so you can recognise your own writes when they come back in the feed.
It returns { results, counts }, one result per address in the order sent:
status | Meaning |
|---|---|
created | Newly suppressed. |
unchanged | Already suppressed. Nothing changed: outcome says what already stands (a complaint stays a complaint). |
resuppressed | It had been reinstated; it is suppressed again. The earlier reinstatement is kept in Journeys' history. |
invalid | Not an email address. Nothing was written for it. |
counts is { created, unchanged, resuppressed, invalid }.
Sending a batch
const res = await fetch('https://apps.waymakerone.com/functions/v1/host-context-api', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.WAYMAKER_CONTEXT_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'journeys_suppress', data: { addresses: batch.map((email) => ({ email })), note: 'opted out in the CRM' } }),
})
if (res.status === 429) {
// Nothing was written. Wait, then send the same batch.
await retryLater(batch, Number(res.headers.get('Retry-After')))
} else if (res.status === 503) {
// Nothing was written. Send the same batch again.
await retryLater(batch, 30)
} else if (!res.ok) {
throw new Error(`${res.status} ${(await res.json()).code}`) // a 400 or 403 will not succeed on retry
} else {
const { results } = await res.json()
for (const r of results) if (r.status === 'invalid') await flagBadAddress(r.email)
}
Sending the same batch twice is safe: the second time every address comes back unchanged.
What the write grant can see and do
Grant journeys:suppressions:write knowing these two things:
- It tells the app what already stands. Each result's
statusandoutcomesay whether an address was already suppressed and why, a complaint included, even for an app without the read grant. Asking about an address that is not on the list puts it on the list. An app can do this for up to 5,000 addresses an hour, and only an org admin can grant it. - It can suppress again an address an admin reinstated. The reinstatement is not lost: Journeys keeps every change in the address's history.
Errors when suppressing
| Status | code | When |
|---|---|---|
400 | too_many_addresses | More than 500 addresses (max: 500). |
400 | note_required | An address with no note, and none on the call. |
400 | note_too_long | A note over 200 characters (max: 200). |
403 | missing_grant | The app does not hold journeys:suppressions:write. |
403 | app_key_required | Called with a person's token or the older env key. |
429 | rate_limited | Over 5,000 addresses in the hour: limit, window_seconds, retry_after_seconds, and a Retry-After header. |
503 | The list could not be written. Nothing was written; send the same call again. |