Waymaker
WaymakerDocs
Developer Documentation
Documentation

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: the next from 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 }.

  • next is where to resume. It is returned even when the page is empty: keep it and poll with it.
  • has_more is true when there is another page ready now. Call again straight away.
  • settle_seconds is 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_idUnique per change. Use it to skip a change you have already applied.
idThe suppression's id. It stays the same across changes, so it is not an idempotency key.
emailThe address, lowercased.
outcomeWhat happened. See below.
occurred_atWhen it happened, from the provider where there is one. This is not the feed order.
reasonWhy it was suppressed: unsubscribe, bounce_hard, bounce_soft, complaint or manual. On reinstated, the reason it had been suppressed for.
noteThe note an app gave when it suppressed the address, or null.
sourcejourneys, 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_idThe campaign the address was suppressed from, or null when there was none or it was not recorded.
workspace_idThe workspace, or null.

Outcomes

outcomeMeaning
unsubscribedThe person used an unsubscribe link.
hard_bouncedMail to the address bounced permanently.
soft_bounce_suppressedMail kept bouncing temporarily until Journeys stopped sending. One soft bounce is not a suppression.
complainedThe person marked a message as spam.
suppressedSuppressed by an app or by hand, or for a reason this list adds later (reason says which).
reinstatedThe 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_id was not recorded before November 2026, so it is null on 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

StatusWhen
403code: "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.
400after is not a cursor this API returned, or limit is not a positive whole number.
401The 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: manual and source: 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:

statusMeaning
createdNewly suppressed.
unchangedAlready suppressed. Nothing changed: outcome says what already stands (a complaint stays a complaint).
resuppressedIt had been reinstated; it is suppressed again. The earlier reinstatement is kept in Journeys' history.
invalidNot 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 status and outcome say 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

StatuscodeWhen
400too_many_addressesMore than 500 addresses (max: 500).
400note_requiredAn address with no note, and none on the call.
400note_too_longA note over 200 characters (max: 200).
403missing_grantThe app does not hold journeys:suppressions:write.
403app_key_requiredCalled with a person's token or the older env key.
429rate_limitedOver 5,000 addresses in the hour: limit, window_seconds, retry_after_seconds, and a Retry-After header.
503The list could not be written. Nothing was written; send the same call again.