Targets
A funnel is built for one target — web or native — and each target forbids what it cannot serve.
Every funnel is created for exactly one target: web or native. The target
decides what the funnel may contain: store purchase blocks live on native
funnels only, the Stripe checkout action and the express checkout block live
on web funnels only, and everything else exists on both.
Fixed at creation
The create body carries an optional target
(apps/api/src/routes/dashboard/funnels.ts:161), which is stored on the row
(apps/api/src/routes/dashboard/funnels.ts:426). The update body has no
target field at all — only expected_target
(apps/api/src/routes/dashboard/funnels.ts:221), the target the builder tab
believes the funnel has. A tab left editing a document for the old target
after a conversion elsewhere gets a 409 FUNNEL_TARGET_CHANGED
(apps/api/src/routes/dashboard/funnels.ts:267,
apps/api/src/routes/dashboard/funnels.ts:487-488) instead of writing, so it
stops saving and says so rather than failing every autosave.
The one action that changes target is the guarded conversion
(apps/api/src/routes/dashboard/funnels.ts:321), through
POST …/funnels/:funnelId/convert
(apps/api/src/routes/dashboard/funnels.ts:1015) — see
Conversion. A PATCH can never do it: the field
simply is not there.
The web path
A web funnel is published to a URL. It gets a slug — the author's, or one
minted from the name (apps/api/src/routes/dashboard/funnels.ts:175,
apps/api/src/routes/dashboard/funnels.ts:182); a web row without one
is refused by the funnels_web_requires_slug check
(apps/api/src/routes/dashboard/funnels.ts:249-251). Once published it is
served at that slug, scoped to its project — see
Hosting. The public loader
(GET /public/projects/:projectId/funnels/:slug) resolves by project and
slug together and answers only for target = 'web'
(apps/api/src/routes/public/funnels.ts:941,
apps/api/src/routes/public/funnels.ts:945): a converted-away row keeps its
history, but no web URL reaches it anymore.
Selling on a web funnel goes through Stripe: the publish route demands an
account that can take a card of a web funnel with a checkout, and refuses
with STRIPE_NOT_CONNECTED otherwise
(apps/api/src/routes/dashboard/funnels.ts:771,
apps/api/src/routes/dashboard/funnels.ts:772). A native funnel is never
asked — its purchase goes through the store.
The native path
A native funnel has no URL. It is stored with a NULL slug
(apps/api/src/routes/dashboard/funnels.ts:181) — a slug invented for it
would be a web address nothing serves — and writing one onto it is an
explicit 400 (apps/api/src/routes/dashboard/funnels.ts:499-500).
Instead it is served to a placement inside the app: a placement resolves to
the published native funnel, served under the funnel envelope key
(apps/api/src/lib/placement-resolution.ts:74,
apps/api/src/lib/placement-resolution.ts:280,
apps/api/src/lib/placement-resolution.ts:306-309). The envelope carries the
row id, which the device stamps into its funnelContext, because the slug
can be null for native funnels
(apps/api/src/lib/placement-resolution.ts:49-52).
What each target forbids
The table below is generated from the one authority both the validator and the
editor's palette read — capabilities(target) in
@rovenue/funnel-core-wasm (packages/funnel-core-rs/src/capabilities.rs).
Do not edit it by hand; run
pnpm --filter @rovenue/docs generate:capabilities after changing the crate.
| Capability | Kind | Only on | Why |
|---|---|---|---|
checkout | action | web | Paying on this page happens through Stripe in the browser, so this is available on web funnels only. |
expressCheckout | block | web | Paying on this page happens through Stripe in the browser, so this is available on web funnels only. |
purchase_selected | action | native | Buying happens in the app store, so this is available on native funnels only. |
purchaseButton | block | native | Buying happens in the app store, so this is available on native funnels only. |
restore_purchases | action | native | Buying happens in the app store, so this is available on native funnels only. |
set_attribute | action | native | Subscriber attributes exist once someone is in the app, so this is available on native funnels only. |
stickyFooter | block | native | Buying happens in the app store, so this is available on native funnels only. |
Same on both targets
Anything absent from the table above exists on both targets — that default is deliberate, so a new block type is available everywhere unless the crate says otherwise.
The editor reads the same table the publish judge applies: the palette and
the action menu disable what the funnel's target cannot carry, with the
crate's own sentence beside it
(apps/dashboard/src/components/funnel-builder/target-capabilities.tsx:12-18).
A forbidden item is shown disabled, never hidden — hiding it would teach the
author nothing. While the table is still loading, every capability shows the
pending reason instead
(apps/dashboard/src/components/funnel-builder/target-capabilities.tsx:47).
And the judge refuses with the same rule: BLOCK_NOT_ON_TARGET and
ACTION_NOT_ON_TARGET
(packages/funnel-core-rs/src/validate.rs:40-41) — so the editor can never
offer what publish refuses. See Publishing for
the full finding tables.
Changing the target later does not rewrite the funnel — it judges what the other target cannot carry first. See Conversion.