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.
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:
| Scope | Endpoints |
|---|---|
canvas:read | List, get, executions, stats, node analytics, versions |
canvas:write | Create, update, duplicate, archive, tags, variants, experiments |
canvas:activate | Activate, pause, resume, publish, rerun, enter user, rollback |
canvas:enter | Enter a user (API trigger) - and nothing else. Carried by canvas:activate |
canvas:delete | Delete, bulk delete |
List Journeys
GET /journeys
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
status | string | - | Filter by status |
tags | string | - | Filter by tags (comma-separated) |
q | string | - | Free-text search |
createdBy / editedBy | string | - | Filter by creator / last editor (comma-separated user ids) |
page | number | 1 | Page number |
limit | number | - | 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
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Journey name (max 255 chars) |
description | string | No | Description (max 1000 chars) |
nodes | array | No | Graph nodes (max 500). A wizard-created draft may start empty |
edges | array | No | Graph edges (max 1000) |
entryTrigger | object | No | How users enter - see below |
variants | array | No | Whole-journey A/B/n variants - see below |
settings | object | No | timezone, quietTime, conversionTracking, reEntryPolicy, personalizedVariants + config |
sendType | string | No | immediate, scheduled, recurring, or trigger |
scheduledAt | string | No | ISO 8601 date for sendType: "scheduled" |
recurringSchedule | object | No | frequency (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. |
targeting | object | No | Audience for scheduled/immediate journeys: userIds, filterGroups, excludeFilterGroups, filterOperator, subscriptionPreference |
exitCriteria | object | No | Same filter shape as targeting; matching active users are exited |
tags | string[] | No | Tags |
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
| Method | Path | Scope | Description |
|---|---|---|---|
POST | /journeys/:canvasId/activate | canvas:activate | Activate - users can start entering |
POST | /journeys/:canvasId/pause | canvas:activate | Pause - stops new entries and advancement |
POST | /journeys/:canvasId/resume | canvas:activate | Resume a paused journey |
POST | /journeys/:canvasId/archive | canvas:write | Archive (halts enrollment/sending, keeps history) |
POST | /journeys/:canvasId/unarchive | canvas:write | Restore to a non-running state; activate explicitly to resume |
POST | /journeys/:canvasId/publish | canvas:activate | Publish 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/rerun | canvas:activate | Re-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
| Method | Path | Description |
|---|---|---|
GET | /journeys/:canvasId/executions | List executions (status, limit max 100, offset) |
GET | /journeys/:canvasId/executions/:executionId | Get 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/context | Execution context variables with inferred types |
GET | /journeys/:canvasId/stats | Journey-level stats (startDate, endDate) |
GET | /journeys/:canvasId/node-analytics | Per-node analytics (startDate, endDate) |
GET | /journeys/:canvasId/variant-stats | Whole-journey variant performance (A/B/n + control) |
GET | /journeys/:canvasId/recovered-carts | The 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/stats | Experiment node stats with significance testing |
POST | /journeys/:canvasId/experiments/:nodeId/declare-winner | Declare a winner (body: pathId) |
POST | /journeys/:canvasId/experiments/:nodeId/reset | Reset an experiment to collecting |
GET | /journeys/:canvasId/personalization-status | Personalized-variants phase status |
Additional Endpoints
| Method | Path | Description |
|---|---|---|
PUT | /journeys/:canvasId/variants | Set whole-journey variants (body: variants array) |
GET | /journeys/:canvasId/versions | List versions |
GET | /journeys/:canvasId/versions/compare?v1=&v2= | Diff two versions |
GET | /journeys/:canvasId/versions/compare-draft | Diff current draft against the last published version |
GET | /journeys/:canvasId/versions/migration-preview | Preview execution compatibility before a migrate-publish |
GET | /journeys/:canvasId/versions/:versionId | Get a version |
POST | /journeys/:canvasId/versions/:versionId/rollback | Roll back to a version. Live journeys only; otherwise 409 journey_not_live. |
GET | /journeys/:canvasId/versions/:versionId/stats | Version-specific stats |
GET | /journeys/:canvasId/versions/:versionId/executions | Executions on a specific version |
GET | /journeys/:canvasId/version-execution-counts | Execution counts per version |
POST | /journeys/:canvasId/versions/:versionId/migrate | Force-migrate compatible executions to a version |
GET | /journeys/:canvasId/history | Audit-log history (limit, max 200) |
POST | /journeys/bulk-delete / bulk-duplicate / bulk-archive / bulk-unarchive | Bulk actions; body { "ids": [...] }, returns { succeeded, failed } |
POST | /journeys/bulk-tag | Bulk tagging; body { "ids": [...], "tags": [...] } |
POST | /journeys/webhook/test | Fire 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-value | Render a Liquid expression against a sample scope |
POST | /journeys/preview-user-updates | Dry-run an Update User node's rows against a sample user |
POST | /journeys/preview-user-update-json | Dry-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-push | Test-send a message node |
POST | /journeys/:canvasId/cleanup-stuck-executions | Clean up stuck executions |
GET | /journeys/:canvasId/executions/:executionId/rendered-message/:nodeId | Rendered 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"
}