Waymaker
WaymakerDocs
Developer Documentation
Documentation

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:

  1. Pull the organisation's suppression list and mark those people opted out in your own records.
  2. Suppress in Journeys the people who opted out in your app.
  3. Upsert the people your app may email onto your canvas, each with a lawful basis, and archive the ones it may no longer email.
  4. 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)

GrantFor
journeys:suppressions:readStep 1: read the suppression list.
journeys:suppressions:writeStep 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:reinstateStep 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 after Retry-After seconds.
  • 503: nothing was written. Send the same call again.
  • 403 missing_grant: the body's grant names 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_type or basis_expires_at clears it, so send them every time.
  • not_owned means 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.

Every field and answer.

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.

The full rules.

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.