Skip to main content

Canvas API Overview

Manage Canvas journeys - multi-step automations built in the visual journey builder - via the REST API.

All endpoints on this page are relative to the base URL: https://api-eu1.joryio.com/api - see API Overview.

Path is /journeys

Canvas endpoints live under the /journeys path - the API resource name for a canvas is a journey. canvasId and journey id refer to the same identifier.

Authentication​

All requests require API key authentication:

Authorization: Bearer jry_live_your_api_key_here
Content-Type: application/json

Required scopes per endpoint group:

ScopeEndpoints
canvas:readList, get, executions, stats, node analytics, versions
canvas:writeCreate, update, duplicate, archive, tags, variants, experiments
canvas:activateActivate, pause, resume, publish, rerun, enter user, rollback
canvas:enterEnter a user (API trigger) - and nothing else. Carried by canvas:activate
canvas:deleteDelete, bulk delete

List Journeys​

GET /journeys

Query Parameters​

ParameterTypeDefaultDescription
statusstring-Filter by status
tagsstring-Filter by tags (comma-separated)
qstring-Free-text search
createdBy / editedBystring-Filter by creator / last editor (comma-separated user ids)
pagenumber1Page number
limitnumber-Results per page (max 100)

Example Request​

curl -X GET "https://api-eu1.joryio.com/api/journeys?status=active&limit=20" \
-H "Authorization: Bearer jry_live_your_api_key"

Response​

{
"data": [
{ "id": "3c9d2f1a-5e8b-4a7c-9f0d-1b2a3c4d5e6f", "name": "Welcome journey", "status": "active" }
],
"pagination": {
"total": 8,
"page": 1,
"limit": 20,
"offset": 0,
"totalPages": 1,
"hasMore": false
}
}

Create Journey​

POST /journeys

Request Body​

FieldTypeRequiredDescription
namestringYesJourney name (max 255 chars)
descriptionstringNoDescription (max 1000 chars)
nodesarrayNoGraph nodes (max 500). A wizard-created draft may start empty
edgesarrayNoGraph edges (max 1000)
entryTriggerobjectNoHow users enter - see below
variantsarrayNoWhole-journey A/B/n variants - see below
settingsobjectNotimezone, quietTime, conversionTracking, reEntryPolicy, personalizedVariants + config
sendTypestringNoimmediate, scheduled, recurring, or trigger
scheduledAtstringNoISO 8601 date for sendType: "scheduled"
recurringScheduleobjectNofrequency (daily/weekly/monthly/custom), cron, dayOfWeek, dayOfMonth, timeOfDay, timezone, endDate, maxOccurrences timeOfDay is HH:MM on the 24-hour clock (09:00, 17:30); any other value is refused with 400.
targetingobjectNoAudience for scheduled/immediate journeys: userIds, filterGroups, excludeFilterGroups, filterOperator, subscriptionPreference
exitCriteriaobjectNoSame filter shape as targeting; matching active users are exited
tagsstring[]NoTags

Node structure​

Each entry in nodes has id, type, config (type-specific), and optional position (x/y for the editor). Valid type values:

trigger, message, in_app_message, delay, condition, behavior_split,
context, update_user, update_wallet, connector, experiment,
ai_decision, ai_agent

There is one message node; the channel is config.channel (email, sms, push, whatsapp, viber, in_app, webhook). The old per-channel node types - email, sms, push, whatsapp, whatsapp_message, wallet, webhook and the in_app alias - were removed and are now rejected on create and update.

Each entry in edges has id, source, target, and optional sourceHandle / targetHandle / label - sourceHandle selects the output port on multi-output nodes (branch groups, behavior-split did / timed_out).

Entry trigger​

entryTrigger has a type and a type-specific config. Valid types: event, api, entity_change, whatsapp_inbound, sms_inbound, viber_inbound, attribute_change, subscription_status, schedule.

Segment Entry is not a trigger. For entries caused by something the person did, use an Event trigger with a filter; for entries caused by time passing, use a recurring journey over the segment.

Whole-journey variants​

Each entry in variants: id, name, percentage (0-100; all variants must sum to 100), isControlGroup (a control variant is a measured holdout with no flow), and triggerNodeId (the trigger node this treatment variant roots at). See journey analytics for how variant results are reported. The field was previously named isControl; that spelling is still accepted on write and normalised to isControlGroup.

Example Request​

curl -X POST https://api-eu1.joryio.com/api/journeys \
-H "Authorization: Bearer jry_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Welcome journey",
"sendType": "trigger",
"entryTrigger": {
"type": "event",
"config": { "eventName": "signed_up" }
},
"nodes": [
{ "id": "n1", "type": "trigger", "config": { "type": "event", "eventName": "signed_up" } },
{ "id": "n2", "type": "delay", "config": { "delayType": "duration", "value": 1, "unit": "days" } },
{ "id": "n3", "type": "email", "config": { "subject": "Welcome!", "html": "<p>Hi {{ user.firstName }}</p>" } }
],
"edges": [
{ "id": "e1", "source": "n1", "target": "n2" },
{ "id": "e2", "source": "n2", "target": "n3" }
]
}'

Returns the created journey object (status draft).


Get Journey​

GET /journeys/:canvasId
curl -X GET https://api-eu1.joryio.com/api/journeys/3c9d2f1a-5e8b-4a7c-9f0d-1b2a3c4d5e6f \
-H "Authorization: Bearer jry_live_your_api_key"

Returns the full journey including nodes, edges, entryTrigger, variants, and settings.


Update Journey​

PUT /journeys/:canvasId

The body accepts the same fields as Create Journey - all optional; only the provided fields are updated.

curl -X PUT https://api-eu1.joryio.com/api/journeys/3c9d2f1a-5e8b-4a7c-9f0d-1b2a3c4d5e6f \
-H "Authorization: Bearer jry_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "name": "Welcome journey v2" }'

Delete Journey​

DELETE /journeys/:canvasId

Returns 204 No Content. Journeys with run history should be archived instead (POST /journeys/:canvasId/archive), which halts enrollment and sending while preserving analytics.


Status Endpoints​

MethodPathScopeDescription
POST/journeys/:canvasId/activatecanvas:activateActivate - users can start entering
POST/journeys/:canvasId/pausecanvas:activatePause - stops new entries and advancement
POST/journeys/:canvasId/resumecanvas:activateResume a paused journey
POST/journeys/:canvasId/archivecanvas:writeArchive (halts enrollment/sending, keeps history)
POST/journeys/:canvasId/unarchivecanvas:writeRestore to a non-running state; activate explicitly to resume
POST/journeys/:canvasId/publishcanvas:activatePublish the current draft as a new version. Body: optional changeSummary, userTransition (keep_on_version / force_exit / migrate_to_new). Only for a journey that is already live (status: active); otherwise 409 with code: "journey_not_live" - go live with POST /journeys/:canvasId/activate.
POST/journeys/:canvasId/reruncanvas:activateRe-run the current published version without creating a new one
curl -X POST https://api-eu1.joryio.com/api/journeys/3c9d2f1a-5e8b-4a7c-9f0d-1b2a3c4d5e6f/activate \
-H "Authorization: Bearer jry_live_your_api_key"

Enter a User (API trigger)​

Enroll a specific user into a journey - the entry path for entryTrigger.type: "api".

POST /journeys/:canvasId/enter/:userId

Permission: canvas:enter (carried by canvas:activate), so an integration such as Zapier can add people to a journey without being able to start, pause or edit it.

Body (optional): context - a JSON object made available to the execution (readable in messages and conditions as journey context variables).

curl -X POST https://api-eu1.joryio.com/api/journeys/3c9d2f1a-5e8b-4a7c-9f0d-1b2a3c4d5e6f/enter/user_123 \
-H "Authorization: Bearer jry_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "context": { "source": "crm-sync", "priority": "high" } }'

List the journeys a person can enter​

GET /journeys/enterable?q=

The live (active) journeys, as { "data": [{ "id", "name", "activatedAt" }] }, sorted by name, at most 100; q filters by part of the name. Permission: canvas:enter (or canvas:read) - an integration that may add people to journeys can pick one without reading the journeys themselves.


Executions and Analytics​

MethodPathDescription
GET/journeys/:canvasId/executionsList executions (status, limit max 100, offset)
GET/journeys/:canvasId/executions/:executionIdGet one execution. Each webhook step in history carries webhook (status, ok, duration, attempts, error, saved fields); a failed call (non-2xx) adds webhook.response ({ status, contentType, body, truncated }) - masked, the first 2 KB of the body, kept 30 days, returned only to a caller with pii:view (an API key only with that scope; others get webhook.responseHidden: true). A successful response body is never stored
GET/journeys/:canvasId/executions/:executionId/contextExecution context variables with inferred types
GET/journeys/:canvasId/statsJourney-level stats (startDate, endDate)
GET/journeys/:canvasId/node-analyticsPer-node analytics (startDate, endDate)
GET/journeys/:canvasId/variant-statsWhole-journey variant performance (A/B/n + control)
GET/journeys/:canvasId/recovered-cartsThe abandoned carts this journey recovered - the list behind revenue.recoveredCarts on /stats, from the same query, so total equals that count for the same startDate/endDate (carts recovered in the range; a date-only end includes the whole day, workspace timezone). limit 1-100 (default 25), offset; newest first. Rows: orderNumber (the store's), placedAt, recoveredAt, match (recovery_link, order = same cart, reported), cartValue/cartCurrency, creditedRevenue/creditedCurrency (net of refunds), contact (the cart's owner) and buyer (only when the order was stored under someone else); contact names are masked without pii:view. Returns { data, total, limit, offset, summary }
GET/journeys/:canvasId/experiments/:nodeId/statsExperiment node stats with significance testing
POST/journeys/:canvasId/experiments/:nodeId/declare-winnerDeclare a winner (body: pathId)
POST/journeys/:canvasId/experiments/:nodeId/resetReset an experiment to collecting
GET/journeys/:canvasId/personalization-statusPersonalized-variants phase status

Additional Endpoints​

MethodPathDescription
PUT/journeys/:canvasId/variantsSet whole-journey variants (body: variants array)
GET/journeys/:canvasId/versionsList versions
GET/journeys/:canvasId/versions/compare?v1=&v2=Diff two versions
GET/journeys/:canvasId/versions/compare-draftDiff current draft against the last published version
GET/journeys/:canvasId/versions/migration-previewPreview execution compatibility before a migrate-publish
GET/journeys/:canvasId/versions/:versionIdGet a version
POST/journeys/:canvasId/versions/:versionId/rollbackRoll back to a version. Live journeys only; otherwise 409 journey_not_live.
GET/journeys/:canvasId/versions/:versionId/statsVersion-specific stats
GET/journeys/:canvasId/versions/:versionId/executionsExecutions on a specific version
GET/journeys/:canvasId/version-execution-countsExecution counts per version
POST/journeys/:canvasId/versions/:versionId/migrateForce-migrate compatible executions to a version
GET/journeys/:canvasId/historyAudit-log history (limit, max 200)
POST/journeys/bulk-delete / bulk-duplicate / bulk-archive / bulk-unarchiveBulk actions; body { "ids": [...] }, returns { succeeded, failed }
POST/journeys/bulk-tagBulk tagging; body { "ids": [...], "tags": [...] }
POST/journeys/webhook/testFire a webhook node config once and return the live response plus the request sent (credentials masked). user.userId renders a real contact from this workspace exactly as a live send; otherwise sample values are used (rate limited)
POST/journeys/preview-context-valueRender a Liquid expression against a sample scope
POST/journeys/preview-user-updatesDry-run an Update User node's rows against a sample user
POST/journeys/preview-user-update-jsonDry-run an Update User node's Custom JSON: { json, sample } in, { errors, preview: { errors, result } } out. errors block saving or activating; preview is the JSON rendered against the sample. Writes nothing
POST/journeys/:canvasId/nodes/:nodeId/send-test-email / send-test-sms / send-test-whatsapp / send-test-pushTest-send a message node
POST/journeys/:canvasId/cleanup-stuck-executionsClean up stuck executions
GET/journeys/:canvasId/executions/:executionId/rendered-message/:nodeIdRendered message for one execution + node

Launch Review​

The same pre-send review campaigns get (see the Launch Review guide), run over every message node: unsubscribe link, merge fields, sender verification, unusable links, subject length, a missing plain-text part, quiet hours, plus node_unreachable for nodes no trigger reaches, cart_variable_no_cart_trigger for a message that reads cart in a journey that does not start on a cart event, and channel_not_set_up for a channel the journey uses that has no provider or sender (params.channel; a blocker, or a recommendation with params.sandbox: 1 in a demo workspace, where the sends are simulated for everyone). Activation and publish run it too.

GET /journeys/{canvasId}/launch-review

Scope: canvas:read. The response has the shape documented under the Campaigns API; a node finding carries scope.kind = "node", scope.id = the node id and fix.nodeId.

When the workspace requires a clean list (settings.launchReview.requireClean, default true) and there are blockers, POST /journeys/{canvasId}/activate and POST /journeys/{canvasId}/publish answer 409 with code: "launch_review_blocked" and the blockers in the body.

Error Responses​

All errors share the standard shape - see Error Response in the API Overview for the format, status codes, and rate-limiting behavior.

{
"statusCode": 404,
"message": "Canvas with ID 8f14e45f-ceea-467f-a11d-2f4b6a1c9e3b not found",
"timestamp": "2026-07-12T09:00:00.000Z",
"path": "/journeys/8f14e45f-ceea-467f-a11d-2f4b6a1c9e3b"
}