Skip to main content

Funnel Canvas

The canvas is the map of one store: where a visitor comes in, which page follows which, what the payment flow and the club and the integrations are doing on the side, and what fires on each wire. Two things make it different from an editor:
  • It never owns your funnel. funnels_v2.config stays the truth. The canvas reads it, and changes it with commands.
  • It changes it one command at a time. Every command is queued per funnel, merged into the config key by key, validated, saved and published. Nothing is written from the browser.
Reading it is canvas.get. Changing it is canvas.apply. Those are the names of the verbs, and this page uses them throughout. On the wire they are tRPC procedures of the CRM, grouped under v3.canvas — they are not REST endpoints you call with an API key, and they will never appear in the API Reference tab, which is generated from openapi.json. An AI assistant reaches them through the Config MCP as canvas_get and canvas_apply. Six procedures exist, and these are the exact paths:
The write procedure is named applyCommand, not apply. tRPC 11 reserves apply, call and then as router keys: the client proxies a router onto a function, and those three are Function.prototype. A router that declares one of them throws while the module loads — Reserved words used in router({}) call: apply — so the procedure could not carry that name. Everywhere the name is not a tRPC path, nothing changed: the verb on this page is still canvas.apply and the MCP tool is still canvas_apply.
All six are gated on your CRM role: reading the canvas needs store read access, changing it needs store management. A store that does not exist and a store of another account answer the same 404 — the account filter is part of the lookup, so neither is an existence oracle.

The document

v3.canvas.get({ storeId, funnelId? }) answers one object. Everything the canvas draws is in it; there is no second call. It never writes: a store that has never been touched by a command reads as an empty canvas at sequence 0, in shadow mode, and no row is created for it. funnel.publish is the honest half of “is it live”:
state is published, no-domain or failed. A compile writes the staging config and mounts its routes in every case; it only promotes to production when the funnel already owns exactly one verified domain. The dot reads “live” when compiledSeq === pendingSeq and publish.state === 'published'.

Kinds

A node carries a kind, and the kind decides what the block can do and which exits it has. entry.* and the satellites are not addable: they follow the store. Connect the storefront, add the domain, create the API key, and the block draws itself on the next read.

Exits

An exit is the port a wire leaves from. Its id is nodeId:kind, or nodeId:custom:name for a named exit. The four rules compile to conditions the runtime already evaluates:
A named exit of an external step is kept in the canvas, drawn, and never lost — but the runtime cannot branch on it yet. Its wire is written as a default wire carrying the name in metadata.canvasExit, so the path actually taken is the highest-priority one. canvas.apply returns a warnings[] entry saying so on the command that creates it.
A condition read back from a funnel the canvas did not write is never dropped. If it names something outside the runtime’s 31 conditions, it comes back as a custom exit with the raw condition as its label and unroutable: true.

The five commands

canvas.apply accepts exactly five verbs. There is no sixth, and there is no free-form patch of the config.

add

Creates a node and, when after is given, the wire that reaches it.
A name is at most 120 characters. A url must be an absolute https:// address. entry.* is refused — see entry-not-addable.

remove

Removes the node and every wire that touched it.

connect

An exit carries exactly one wire. Connecting an exit that already has one is refused (exit-already-connected), and so is a wire that closes a loop (cycle).

bind

Attaches an event of an integration to the store, a node, or a wire.
target is { "kind": "store" }, { "kind": "node", "nodeId": "…" } or { "kind": "edge", "nodeId": "…", "exit": "…" }.
event is the platform topic, namespace/name, not the name the destination shows. order/paid, checkout/initiated, subscription/started — never Purchase, never Order Paid. The subscriber matches this string against the topic of the event as it leaves the bus, so a destination-side label matches nothing and the marker never fires. The name the destination will see for that topic is in v3.canvas.emissions, keyed by the same provider × topic pair.This holds for store and edge targets, which the server dispatches. A node target is a browser marker — the pixel of a page — so its event is one of the browser event names of that destination, listed as browserEvents in v3.canvas.emissions: PageView, InitiateCheckout, Purchase, and AddToCart / ViewContent / Search and friends depending on the provider.
bind always writes a row of a canvas-owned table at accept time, in the same transaction as the journal, so the marker the drawer shows is always one the queue actually wrote. Binding the same event twice on the same place toggles that row instead of adding a second one — two rows would fire the pixel twice. A store or edge target stops there and changes nothing in funnels_v2.config. A node target also writes the page pixel: the next compile merges the event into node.config.stepConfig.pixels[provider], an owned sub-key, taking the pixel id from the integration’s own settings. It is a merge, never a rewrite — a pixel you set up outside the canvas keeps its id, its other events, its enabled flag and its Google Ads settings, and a second pixel of the same provider is left alone. enabled: false clears that one event, and drops the entry when it was its last one. Four providers carry a page pixel: facebook, tiktok, snapchat and gtm. Any other destination, an event it does not send from the browser, or an integration with no pixel id yet, comes back as a warning on the reply — the marker row is written, the page is not.

set

Changes one node in place. Every field is optional, at least one must be present, and a field that does not apply to the node it lands on is refused.
name · index · path · url · template · offerId · productId · priceId · quantity · exits · rule · variants.

The envelope

commandId is yours to generate, and replaying it is safe. A commandId already seen returns the original reply — the same document, the same sequence number — and never a conflict and never a second command. Retry on a timeout with the same id; generate a new one only for a genuinely new command. expectedSeq is the funnel.pendingSeq you read. If someone else moved the canvas in between, the command comes back stale with the fresh document, so you can re-read and re-send in one round trip.

The reply is a union of three, and all three are HTTP 200

Branch on status. A refusal is data, not an exception: it travels inside the reply, with the reason and the remedy, because the tRPC error formatter of the CRM only lets zodError through and would drop code, cause, fix and docs on the way out.
compile is pending when the funnel is queued for a compile, skipped in shadow mode.
For an agent this is the useful shape: fix is an instruction it can act on without a human, and docs is the page that explains it. Branch on code — it is versioned and never changes meaning. message, cause and fix are prose and may be sharpened with the actual value.
A malformed command is not a refused reply — it is an HTTP 400 with a zod error. The input schema runs before any of this, so a name over 120 characters, an unknown key, a missing expectedSeq or an http:// address never reach the refusal union. What comes back is the standard tRPC zodError, and the offending field is named in it:
Read both shapes: the 400 tells you which field is wrong, the refused reply tells you which rule you broke. The catalogue codes below that a well-formed command can never reach — name-too-long and url-not-https — exist for callers that build a command somewhere else and want the same wording.
Three things are still thrown errors, so handle them as errors and not as a status:
  • 404 — an unknown or foreign storeId, on every procedure of the group.
  • 412v3.canvas.setMode going live while the canvas holds commands that never compiled.
  • 429v3.canvas.applyCommand when an agent actor passes rate-limited.

What happens after a command is accepted

The merge only writes the keys the canvas owns — a name, a type, an entry flag, an index, order bumps, upsell offers, payment and resource settings, pixels, variants, the address of an external step or a storefront (the one URL you typed yourself), and the wires. Positions, instance ids, mount points, plugin versions, screenshots and routing are read and put back untouched, which is what lets the V2 editor, a Studio deploy and the screenshot job keep working on the same funnel.

Shadow mode

A store starts in shadow. Commands are accepted and journalled, the merge and the validation run, the diff is logged — and nothing is written to the funnel. compile comes back as skipped. v3.canvas.setMode switches it. Going live while pendingSeq !== compiledSeq is refused with an HTTP 412, not a typed refusal: the procedure answers with the mode and nothing else, so there is no channel for a body. The message says so — “This canvas cannot go live while it holds commands that were never compiled.” Either run canvas.reimport to drop them, or read the shadow diff in the logs and accept it, then switch again.

Drift

The canvas remembers what the funnel looked like at the last compile — an updated_at token plus a hash of the config with every key the canvas does not own projected out. If someone changed the funnel outside the canvas, funnel.drift is true. A command is still accepted while a funnel is in drift. It takes its sequence number and is journalled as usual; it is the compile that stops, writes nothing, and logs why. Nothing anyone wrote outside the canvas is ever overwritten. canvas.reimport is the way out: it takes the funnel as it now stands as the new base and marks every command that never compiled as failed, with drift as the reason — that reason is where you read the code, not the reply to the command itself.

Error catalogue

Fifteen codes, versioned. A code never changes meaning; a new one is additive. Every refusal carries a message (what happened), a cause (why), a fix (what to do) and a docs link to the section below. Most of them reach you as a refused reply on an HTTP 200. Four do not, and knowing which saves an hour: stale is the third branch of the union, also a 200; graph-not-found is an HTTP 404; rate-limited is an HTTP 429; drift is the reason stamped on commands that canvas.reimport discards, and the HTTP 412 that v3.canvas.setMode throws. name-too-long and url-not-https describe rules the input schema already enforces, so a command sent to tRPC or to the MCP tool hits the HTTP 400 with a zod error first.

stale

expectedSeq does not match the graph pendingSeq: another command was accepted between the read and this write. You also get it when you ask for a second funnel of the same store while the queue of the first is not empty. It arrives as { "status": "stale", "document", "seq" } on an HTTP 200, not as an error. Fix. Take the document returned with it and send the command again with its funnel.pendingSeq as expectedSeq. Re-read and retry — it is not a failure to show the merchant.

unknown-node

No node of the current document carries that id: it was removed, or it belongs to another funnel. Fix. Call canvas.get again and take the id from document.nodes[].id.

unknown-exit

The exit id is not in the exits of the node it is attached to: a checkout has paid and failed, an offer accepted and declined. Fix. Read document.nodes[].exits[].id for that node and use one of the values it lists.

unknown-integration

The integration was deleted, or it belongs to another store of the account. Fix. Take an id from document.satellites[] where kind is integration, or connect the integration on the store first.

foreign-id

Every id a command names — offer, product, price, integration, club, API key, template, funnel — is checked against the caller account before anything is written. Fix. Replace it with an id returned by the matching read for this account.

node-limit

A canvas graph is capped at 200 nodes and this command would push it past that. Fix. Remove a node the funnel no longer serves, or move the tail of the flow into a second funnel, then send the command again.

name-too-long

Names are written into the funnel config and drawn on the card; 120 characters is the ceiling. Over tRPC and over the MCP tool the input schema catches it first, so what you actually receive is an HTTP 400 naming command.props.name or command.patch.name. The code exists for a caller that builds the command elsewhere and wants the same wording. Fix. Shorten the name to 120 characters or fewer, then send the command again.

url-not-https

The value is not an absolute https:// URL: http://, a bare host and a relative path are all refused.
An http:// address does not come back as a refused reply. It is caught by the input schema, so what you get is an HTTP 400 with the field named:
Both shapes are readable, but they are not the same shape. Handle the 400 on the field, not in the refusal branch.
Fix. Give the full address, scheme included, for example https://shop.example.com/thanks.

entry-not-addable

entry.storefront, entry.address and entry.api are read from the store — its storefront integration, its domains, its API keys — and never written into the funnel. Fix. Connect the storefront, add the domain or create the API key on the store, then reload the canvas: the entry draws itself.

exit-already-connected

An exit routes to exactly one node; a second wire would leave the runtime with two candidates on the same condition. Fix. Remove the wire that leaves this exit first, or connect a different exit of the same node.

cycle

The node in from is already reachable from the node in to, so the runtime would walk between the two forever. Fix. Point the wire at a node that comes after from, or remove the wire that closes the loop first.

validate-funnel-failed

Validation reported at least one error on the resulting config: a missing page configuration, a duplicate path, an orphan step or a missing entry step. Fix. Read the issues logged with this command, fix the node it names, and send the command again.

drift

The funnel no longer matches the snapshot the canvas last compiled from: the V2 editor, a Studio deploy or a script wrote to it. You meet this code in two places, and never as the answer to a command: stamped on the commands canvas.reimport discards, and as the HTTP 412 v3.canvas.setMode throws when the canvas still holds commands that never compiled. Fix. Run canvas.reimport to take the funnel as it stands as the new base, then send the command again.

rate-limited

An agent actor is capped at 60 commands per minute per graph, so one script cannot starve the merchant editing the same funnel. A command sent from the canvas screen by a signed-in user is not an agent and is not counted. It arrives as an HTTP 429, thrown, not as a refused reply. Fix. Wait for the current minute to pass, and group what you can into fewer commands before sending again.

graph-not-found

The storeId names no store of the caller’s account: it does not exist, or it belongs to someone else. The account filter sits in the same where as the id, so the two answer identically and neither confirms that a store exists. It arrives as an HTTP 404, thrown, not as a refused reply. Fix. Send a storeId this account owns. The name of the code is a leftover: the graph row is not what is missing, and the read never creates one either — the row appears the first time a command is accepted for the store, and v3.canvas.get on a store without one answers an empty canvas at sequence 0, not this error.

Next

External Steps

Put a page you host on the canvas, and report what happens on it.

Config MCP

canvas_get and canvas_apply from an AI assistant.