Connect your app to a Journeys canvas
This is the whole job, end to end: your app (a CRM, say) keeps Journeys in step with the consent it holds, so a Journeys campaign mails the people your app has consent for and nobody who opted out anywhere. Each step links to its full reference.
What your app ends up doing, every 15 minutes:
- Pull the organisation's suppression list and mark those people opted out in your own records.
- Suppress in Journeys the people who opted out in your app.
- Upsert the people your app may email onto your canvas, each with a lawful basis, and archive the ones it may no longer email.
- Reinstate people who opted back in through your app (consent reinstatement).
Nobody at Waymaker needs to be involved. Two people in your organisation do: an org admin, for the grants, and whoever manages the app.
1. Create the canvas and copy its id
The canvas is the audience your app writes to. A Journeys campaign sent to Members with consent on that canvas mails the people your app put there.
In Commander: open Journeys, create a canvas (call it something like "CRM members"), then open its Canvas Settings (the gear). Canvas ID is there with a copy button, and the line under it copies the whole grant name your app needs.
From an agent: the MCP tools commander_journey_canvas_create (workspace_id, title) and
commander_journey_canvas_list. Each canvas comes back with its full id.
From the CLI: waymaker journeys canvases create --workspace <workspace-id> --title "CRM members" and
waymaker journeys canvases list. These arrive with the next CLI release; until then, use Commander or MCP.
Use one canvas per app. Your app can only change the memberships it created, so a shared canvas gives it nothing.
2. Give the app its context key
Your app's server calls Journeys with the app's own context key. A person's API key and the
older X-API-Key are refused by every Journeys write.
waymaker host apps context-key create <app-id> # placed in the app's env as WAYMAKER_CONTEXT_KEY; never shown
waymaker host apps deploy <app-id> # the key reaches the app on its next deploy
From an agent: host_app_context_key_create, then deploy. The owner or releaser of the app's Solution can do this,
or, outside a Solution, the app's creator or an org admin.
3. Grant the app what it needs (org admin)
| Grant | For |
|---|---|
journeys:suppressions:read | Step 1: read the suppression list. |
journeys:suppressions:write | Step 2: suppress people who opted out in your app. |
journeys:audience:write:<canvas id> | Step 3: put people on that one canvas, and take them off. |
journeys:consent:reinstate | Step 4: lift an opt-out when someone opts back in through your app. Leave it off until your app collects express consent. |
All four are granted by name. * covers none of them, and only an org admin can add them.
waymaker host apps update <app-id> --commander-permissions \
"journeys:suppressions:read,journeys:suppressions:write,journeys:audience:write:<canvas id>,journeys:consent:reinstate"
From an agent: host_app_update with the same names in commander_permissions.
The list replaces what the app had. Check what it holds first (host_app_get, commander_permissions) and
include those names too. Write the canvas id in lowercase.
Before you add them, read what each one lets the app see and do: the write grant and reinstatement. Removing a grant needs no admin and takes effect on the app's next call.
4. One client for every call
Every action is a POST to the same URL, with the context key as a bearer token. Server side only: never put the
key in a variable your framework compiles into the browser bundle.
const ENDPOINT = 'https://apps.waymakerone.com/functions/v1/host-context-api'
class JourneysError extends Error {
constructor(message: string, readonly status: number, readonly code: string | null, readonly retryAfter: number | null) {
super(message)
}
}
async function call<T>(action: string, data: Record<string, unknown>): Promise<T> {
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.WAYMAKER_CONTEXT_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ action, data }),
})
const json = await res.json().catch(() => null)
if (!res.ok) {
const retry = Number(res.headers.get('Retry-After') ?? NaN)
// Messages never contain an address, so this is safe to log.
throw new JourneysError(`${action}: ${res.status} ${json?.code ?? ''}`, res.status, json?.code ?? null,
Number.isFinite(retry) ? retry : null)
}
return json as T
}
Errors to plan for, on every action:
- 429
rate_limited: over the hourly cap. Nothing was written. Send the same call again afterRetry-Afterseconds. - 503: nothing was written. Send the same call again.
- 403
missing_grant: the body'sgrantnames what to ask the admin for. Retrying will not help. - 403
app_key_required: the call was not made with the context key.
5. Pull the suppression list
The first call, with no cursor, returns the whole list. After that, poll from the cursor you saved.
type Change = { event_id: string; email: string; outcome: string; reason: string | null; source: string | null }
async function pull() {
let after = await loadCursor() // null on the first run
for (let page = 0; page < 20; page++) { // up to 10,000 changes a run; the rest wait for the next
const r = await call<{ suppressions: Change[]; next: string; has_more: boolean }>(
'journeys_suppressions', { ...(after ? { after } : {}), limit: 500 })
for (const s of r.suppressions) {
if (s.source === `app:${MY_APP_ID}`) continue // your own suppress coming back
if (await alreadyApplied(s.event_id)) continue // event_id is the idempotency key, not id
await applyOutcome(s.email, s.outcome) // a state, not a delta: the latest per address
}
after = r.next // returned even on an empty page
await saveCursor(after) // after applying, so a crash re-reads, never skips
if (!r.has_more) break
}
}
Hold the change for an address your app does not have yet, and apply it when the address appears. Once a day, also
read from no cursor to the end, skipping event_ids already applied.
Fields, outcomes and the push alternative.
6. Suppress the people who opted out in your app
async function push(optedOut: string[]) {
for (let i = 0; i < optedOut.length; i += 500) { // 500 a call, 5,000 an hour
const batch = optedOut.slice(i, i + 500)
const r = await call<{ results: { email: string; status: string; outcome?: string }[] }>(
'journeys_suppress', { addresses: batch.map((email) => ({ email })), note: 'opted out in the CRM' })
await markSuppressed(r.results.filter((x) => x.status !== 'invalid').map((x) => x.email))
}
}
created, unchanged and resuppressed all mean the address is now suppressed. Sending a batch twice is safe.
7. Put people on the canvas, and take them off
Archive first, so someone who just opted out is off the audience before anyone is added. Archiving is not an unsubscribe: it only takes the person out of your app's audience.
const CANVAS = process.env.JOURNEYS_CANVAS_ID!
await call('journeys_audience_archive', { canvas_id: CANVAS, members: leaving.map((email) => ({ email })) })
await call('journeys_audience_upsert', { canvas_id: CANVAS, members: joining.map((p) => ({
email: p.email,
lawful_basis: p.isCustomer ? 'contract' : p.optedIn ? 'express_opt_in' : 'legitimate_interest_b2b',
contact_type: 'business', // legitimate_interest_b2b needs it
note: 'Waymaker CRM',
})) })
- Each upsert states the whole basis. Leaving out
contact_typeorbasis_expires_atclears it, so send them every time. not_ownedmeans someone else put that person on the canvas. Record it and stop sending that person; a retry gets the same answer.- A membership is not permission to mail. Journeys checks the basis, the person's own opt-out and the suppression list at every send. Upserting someone who opted out never makes them mailable.
- One address per person: send the primary one.
8. Reinstate people who opted back in
Only when the person ticked "email me" (or the like) in your app, after they had opted out.
const r = await call<{ results: { email: string; status: string; reason?: string }[] }>(
'journeys_consent_reinstate', { members: optedBackIn.map((p) => ({
email: p.email,
basis: 'express_opt_in',
consented_at: p.consentedAt, // when THEY consented, with an offset or Z: not now()
note: 'ticked "email me" on the CRM preference page',
})) })
reinstated: Journeys may mail them again.unchanged: there was nothing to lift.not_lifted: record it and do not retry. It covers a consent no newer than their latest opt-out, a spam complaint, a permanent bounce and a suppression someone else made, without saying which. Never resend with the current time to get past it.- The person must be on your canvas (any status). 100 a call, 1,000 an hour. On a 429 or 503, send the identical call again.
9. Run it every 15 minutes with an Ambassador
Keep the sync in your app, behind a route that needs a shared secret, and let an Ambassador call that route on a schedule. The sync logic, its database and its tests stay in one place.
// ambassadors/journeys-sync/index.ts
export default async function handler(request: Request, ctx: { env: Record<string, string | undefined> }) {
const secret = ctx.env.SYNC_SECRET
if (!secret) return Response.json({ error: 'SYNC_SECRET is not set' }, { status: 503 })
const res = await fetch(ctx.env.SYNC_URL!, { method: 'POST', headers: { 'x-sync-secret': secret } })
return new Response(await res.text(), { status: res.status, headers: { 'content-type': 'application/json' } })
}
waymaker host ambassadors create journeys-sync --repo https://github.com/<owner>/<repo> --branch main \
--path ambassadors/journeys-sync/index.ts --schedule "*/15 * * * *"
waymaker host ambassadors env set <id> SYNC_SECRET=<secret> # the same secret in the app's env
waymaker host ambassadors env set <id> SYNC_URL=https://<your app>/api/sync/journeys
waymaker host ambassadors deploy <id> # a schedule takes effect on the next deploy
In the app's route:
- Check the secret, and refuse anything else.
- Refuse a run while another is in flight. A slow run must not overlap the next one.
- Return counts, never addresses. Its answer ends up in the Ambassador's logs.
waymaker host ambassadors logs <id> shows each run: a scheduled one has the trigger cron. If the Ambassador is in a
Solution with the release policy, it changes only through a release, so deploy is refused.
Older apps: X-API-Key and X-App-Id
Apps built before context keys sent a key from their env as X-API-Key, with X-App-Id. That still works for
reads such as commander_team_ids, but it is deprecated, and every Journeys write refuses it. Move every call to
Authorization: Bearer $WAYMAKER_CONTEXT_KEY, then delete the old key:
moving off WAYMAKER_API_KEY.