================================================================================ API DOCUMENTATION Generated: 2026-09-06T08:07:02.221Z ================================================================================ BASE URL: http://salesfunnels.com AUTHENTICATION -------------- Bearer token required in Authorization header: Authorization: Bearer Get token via OAuth flow at: http://salesfunnels.com/auth CORE ENDPOINTS -------------- Authentication: GET /api/auth/me - Get current user info POST /api/auth/logout - Logout current session POST /api/auth/refresh - Refresh access token Entities: Probe_results: GET /api/entities/probe_results - List all probe_resultss GET /api/entities/probe_results/{id} - Get single probe_results GET /api/entities/probe_results?search= - Full-text search (key, payload) Fields: key, payload Msai_sso_probes: POST /api/entities/msai_sso_probes - Create new msai_sso_probes Msai_pending_connects: POST /api/entities/msai_pending_connects - Create new msai_pending_connects Msai_intents: POST /api/entities/msai_intents - Create new msai_intents 22 additional private entities not shown. Authenticate with a Bearer token to see full entity documentation. Custom Endpoints: GET /api/msai/agent/funnels [Public] MSai agent read: list the funnels in ONE MSai WORKSPACE, or fetch ONE funnel's CANVAS in full with ?id=. Server-to-server only, fail-closed on X-MSai-Key (503 NOT_CONFIGURED when unset, 401 invalid_key on mismatch). SCOPE IS THE WORKSPACE, NOT THE USER: workspace comes from the signed workspace_id claim (Authorization: Bearer JWT wins, else X-MSai-Token) or ?workspace_id= in key mode; msai_user_id is LOGGED ONLY. THREE RESOLUTION MODES, in this order. (1) LINK + workspace_ref: every non-archived funnel whose workspace_ref matches the linked Sales Funnels WORKSPACE, read across ALL its members, so a teammate's board is included and the account's boards in other workspaces are excluded; scope_source ends in "+workspace_ref". (2) LINK without workspace_ref (older links): unchanged account-wide behavior, "+explicit_link". (3) NO LINK: the pre-existing membership-from-msai_link path, byte-identical, 404 workspace_not_found. SINGLE MODE (?id=) returns the board as the canvas renders it — NODES AND EDGES, NO PAGE COPY: pages[] ordered by sort_order, each {id,type,type_name,page_type_id,name,position,x,y,status}, and connections[] each {id,from_page_id,to_page_id,label,custom_label,from_side,to_side}. The headline/subheadline/body/cta_text/cta_url fields were REMOVED in the canvas correction — a funnel in this app is a visual mockup, and page_content is no longer read here. `label` is returned exactly as the board stores it: lowercase 'yes'/'no'/'custom'/'' (the UI uppercases only at render), with the free text in custom_label when label is 'custom'. sequence_steps[] is retained unchanged. The funnel is located ONLY within the resolved scope, so ?id= for a funnel in another workspace is 404 funnel_not_found — identical to a missing row. LIST MODE also carries template {slug,name}, page_count, sequence_step_count. Never returns overskill_user_id, email, or last_edited_by. PHASE 17 — LIST-ONLY PAGINATION/SEARCH/STATUS, canonical block AGENT_LIST_QUERY_V1 (grep the marker): optional page (default 1, min 1), per_page (default 20, min 1, max 100), search (case-insensitive contains against funnel name), status (exact match against funnel.status). Filtering is applied BEFORE slicing, so `total` counts MATCHES and not the raw table. Applied identically in BOTH list branches (explicit-link and legacy) via one local helper, so paging does not depend on whether the workspace is connected; the ?id= single path never reaches it and is unchanged. include_archived remains an independent axis — status=archived only returns rows when include_archived=true, because archived rows are excluded upstream. Response gains total, page, per_page, total_pages (total_pages is 0 when total is 0). TWO SEMANTIC NOTES for existing callers: `total` is NEW on this endpoint (it did not exist before), and `count` now means "funnels on THIS page" rather than "all funnels" — a caller passing no params is served the first 20 matches, so a workspace with more than 20 boards is truncated unless it pages or raises per_page. GET /api/public/funnel-image [Public] PUBLIC (requires_auth:false): stream an image for the public funnel view. Validates the token against the synthetic 'public-links' scope (exact match, reject revoked), then verifies the requested image belongs to THAT funnel: kind=attachment -> the attachment row's funnel_id; kind=swipe -> an ACTIVE page_swipe_pick on one of the funnel's pages references that swipe_file_id; kind=type-icon -> one of the funnel's pages has that page_type_id; kind=board-bg -> the funnel's own bg_image_key (no id needed, and any supplied id is ignored — the token already names the funnel). Streams bytes from storage. 404 on any mismatch. No other public surface. PATCH /api/msai/agent/pages/:id [Public] MSai agent write: update ONE page NODE on a funnel's canvas — name, type, position and status only. Copy fields (headline/subheadline/body/cta_text/cta_url) were REMOVED in the canvas correction: a funnel here is a visual mockup, so this endpoint no longer reads or writes page_content at all. Server-to-server only, fail-closed X-MSai-Key (AGENT_GATE_V1). Workspace from body.workspace_id via msai_workspace_link (AGENT_LINK_V1). The page is found by scanning the linked workspace's member tenants, then its PARENT FUNNEL is re-checked with the same workspace_ref rule as AGENT_FUNNEL_LOCATE_V1 — so a page whose funnel lives in another workspace returns 404 page_not_found and cannot be written. This is the endpoint the acceptance test's cross-workspace check exercises. PARTIAL UPDATE: only keys present in the body change. `type` is re-resolved via PAGE_TYPE_RESOLVE_V1 and a bad value is a 422 that changes nothing. `x`/`y` move the node on the canvas (numeric, else 422). `status` accepts active|archived only — archiving is the app's only delete, and there is no hard-delete endpoint. 200 {ok:true,data:{page}} in the same node shape the create endpoints return. POST /api/msai/agent/funnels/:id/sequence-steps [Public] MSai agent write: add a follow-up sequence step to a funnel. Server-to-server only, fail-closed X-MSai-Key (AGENT_GATE_V1); workspace via msai_workspace_link (AGENT_LINK_V1) and the funnel via AGENT_FUNNEL_LOCATE_V1, so a funnel outside the linked workspace is 404 funnel_not_found. IMPORTANT SHAPE NOTE: sequence_step.page_id is REQUIRED in this app — steps hang off a PAGE, not a funnel, because that is how the board renders them. The body may name a page_id (validated to belong to THIS funnel, else 422); when omitted the step attaches to the funnel's FIRST page by sort_order, and a funnel with no pages is a 422 telling the caller to add a page first rather than writing an orphan row the UI could never show. channel accepts email|sms|text and is normalized to the stored enum (email|text) — `sms` is stored as `text`. delay_amount + delay_unit (minutes|hours|days) are stored verbatim AND converted into the pre-existing day_offset the app's UI sorts by (minutes/hours round DOWN to whole days, so a 30-minute step is day 0 — it sorts first without inventing a sub-day unit the board cannot draw). `subject` maps to the required title column; a step with no subject falls back to a channel-derived title. body is required and non-empty. 201 {ok:true,data:{step}}. GET /api/msai/agent/page-types [Public] MSai agent read: the page-type vocabulary the write endpoints accept. Server-to-server only, same fail-closed X-MSai-Key gate as /api/msai/agent/funnels (503 NOT_CONFIGURED, 401 invalid_key). Needs NO workspace — the catalog is global, read from the synthetic 'catalog' tenant. Returns every ACTIVE page type as {id, type, name, description, aliases[], default_fields[]}, where `type` is the canonical slug a write endpoint should send and `aliases` are the other spellings that resolve to it (PAGE_TYPE_RESOLVE_V1, byte-identical in every write handler). IMPORTANT: default_fields are SUGGESTIONS for what to fill on that type, not a schema — every page accepts the same writable set (name, slug, headline, subheadline, body, cta_text, cta_url), and no field is required beyond the type itself. This endpoint exists so the agent never has to guess a type string: resolution is exact-id, then canonical slug, then the short slug (trailing -page/-form dropped), then a punctuation-insensitive compact match, then the alias table. POST /api/msai/agent/funnels/:id/pages [Public] MSai agent write: add ONE page NODE to an existing funnel's canvas. Nodes only — this endpoint writes no page copy and never touches page_content. Server-to-server only, fail-closed X-MSai-Key (AGENT_GATE_V1); workspace via msai_workspace_link (AGENT_LINK_V1); the funnel via AGENT_FUNNEL_LOCATE_V1, which requires the live row's workspace_ref to match the link's — so a funnel in another workspace is indistinguishable from a missing one (404 funnel_not_found) and a cross-workspace write is impossible. Written into the FUNNEL OWNER'S tenant (never the agent's) and stamped created_by_msai_user_id (attribution only). PLACEMENT (AGENT_LAYOUT_V1): `after_page_id` (must belong to THIS funnel, else 422) places the node one card-pitch to the RIGHT of that page at the same y and takes sort_order just after it; omitted, the node is appended one pitch right of the current right-most node. Pitch is CARD_W 200 + GAP 80, matching the create endpoint and the UI's own add-page — never the origin, so a new node cannot stack on an existing one. `type` is required and resolved via PAGE_TYPE_RESOLVE_V1; an unresolvable type is a 422 listing valid slugs, never a silent guess. 201 {ok:true,data:{page}}. PATCH /api/msai/agent/funnels/:id [Public] MSai agent write: update a funnel's name, description or status. Server-to-server only, fail-closed X-MSai-Key (AGENT_GATE_V1); workspace via msai_workspace_link (AGENT_LINK_V1) and the funnel via AGENT_FUNNEL_LOCATE_V1, so a funnel in another workspace is 404 funnel_not_found and cannot be touched. PARTIAL UPDATE — only keys present in the body change. status accepts draft|active|archived; setting archived is the app's soft-delete and REMOVES the board from the dashboard and from every agent read, which is why it is called out here: there is no hard-delete endpoint, so this is the only way an agent can retire a funnel, and it is reversible by patching the status back. workspace_ref is deliberately NOT writable — moving a board between workspaces from an agent key would let one workspace push a board into another, which is exactly the boundary this API exists to enforce. Writes into the tenant that already holds the row and stamps last_edited_by, matching the app's own PATCH /api/funnels. 200 {ok:true,data:{funnel}}. GET /api/public/funnel-templates [Public] PUBLIC (requires_auth:false): the Factory template list for the funnel a share token proves, so a shared /view board can render the owner's selected template screenshots instead of the built-in wireframes. The slug is READ OFF THE FUNNEL server-side and is NEVER taken from the caller — a share token therefore unlocks exactly one template set (the one attached to that board) and can't be used as a general Factory API key. Returns {slug,name,templates:[{slug,name,page_type,thumbnail_url,screenshot_url,position,has_code}]} — display fields only, never template source code. A funnel with no template selected returns 200 with an empty list (a valid state, not an error). 404 on unknown/revoked token. POST /api/msai/agent/funnels [Public] MSai agent write: create a funnel as a CANVAS MOCKUP — page nodes laid out left-to-right, optionally wired into a linear flow. A funnel in this app is a visual board (nodes + edges), NOT page copy: this endpoint writes NO headline/subheadline/body/cta and touches page_content never. Server-to-server only, fail-closed X-MSai-Key (AGENT_GATE_V1); workspace from body.workspace_id via msai_workspace_link (AGENT_LINK_V1), 404 workspace_not_found when unlinked. Rows land in the LINK OWNER'S tenant stamped workspace_ref (what makes the board appear on that workspace's dashboard) + created_by_msai_user_id from body.msai_user_id (attribution only, never access). LAYOUT (AGENT_LAYOUT_V1): nodes are placed on ONE row at y=0, x = i*(CARD_W 200 + GAP 80), matching what the UI's own add-page does — the previous 5-per-row wrap used a 220px vertical step against a 236px CARD_H, which overlapped. connect_linear:true additionally creates page[i]->page[i+1] edges with from_side 'right' / to_side 'left', the exact anchors the canvas uses for a left-to-right flow. ATOMICITY: every `type` is resolved via PAGE_TYPE_RESOLVE_V1 BEFORE any row is written, so the common failure (a bad type string) can never half-create a board; if a write still fails mid-way the funnel, its pages and its connections are hard-deleted (rows are seconds old, no history) and the response is 500 rolled_back:true. 201 {ok:true,data:{funnel:}}. POST /api/msai/agent/funnels/:id/connections [Public] MSai agent write: add ONE edge between two page nodes on a funnel's canvas — this is how a YES/NO branch is drawn. Server-to-server only, fail-closed X-MSai-Key (AGENT_GATE_V1); workspace via msai_workspace_link (AGENT_LINK_V1) and the funnel via AGENT_FUNNEL_LOCATE_V1, so an edge can never be written into a funnel outside the linked workspace (404 funnel_not_found). BOTH endpoints must be active pages on THIS funnel, else 422 — never a dangling edge the canvas cannot draw. Self-edges are 422. A duplicate active from->to pair is 409 (matches the UI's own connectionExists guard in src/lib/connection-math.ts). LABEL VOCABULARY, verified against live rows and src/pages/Funnel.tsx: the board stores LOWERCASE 'yes' | 'no' | '' and uppercases only at render, and the option list is exactly ["yes","no","none","custom"] — so a caller sending "YES" is NORMALIZED to 'yes'; sending it verbatim would miss the conn-yes/conn-no styling and draw a plain neutral arrow. Any OTHER non-empty string is stored the way the UI stores one: label 'custom' + the text in custom_label, truncated to the UI's 20-char cap. 'none'/''/omitted means an unlabelled edge. SIDES: derived from the two nodes' live positions via the same dominant-axis rule as deriveSides() — right->left for a left-to-right flow, bottom->top when the target sits below — so an agent-made branch anchors exactly like a hand-drawn one. 201 {ok:true,data:{connection}}. GET /api/msai/agent/sequences [Public] MSai agent read: follow-up sequence steps for ONE MSai WORKSPACE, grouped by funnel page. Server-to-server only, same fail-closed gate as /api/msai/agent/funnels (503 NOT_CONFIGURED, 401 invalid_key / bad_signature / token_expired). SCOPE IS THE WORKSPACE, NOT THE USER: workspace comes from the signed workspace_id claim (Authorization: Bearer JWT wins, else X-MSai-Token) or ?workspace_id= in key mode; msai_user_id is LOGGED ONLY. THREE RESOLUTION MODES, in this order. (1) LINK + workspace_ref (the workspace model): steps are returned for every non-archived funnel whose workspace_ref matches the linked Sales Funnels WORKSPACE, read across ALL of that workspace's members, so a teammate's funnel is included and the account's funnels in other workspaces are excluded; scope_source ends in "+workspace_ref". (2) LINK without workspace_ref (older links): unchanged account-wide behavior, tags ignored, "+explicit_link". (3) NO LINK: falls through to the pre-existing membership-from-msai_link path byte-identically, 404 workspace_not_found. The workspace boundary is ALWAYS enforced on the PARENT FUNNEL — a sequence_step has no workspace column of its own, so a step can never appear without its funnel. Optional funnel_id narrows to one funnel in every mode. Never returns overskill_user_id or any email. POST /api/msai/connect/verify [Public] PUBLIC verification for the /msai/connect confirm page. TWO sources, tried in order. (1) INTENT_STASH_V1 — an opaque handle from the body or the msai_intent cookie is resolved against the server-side msai_intent row written by GET /api/sso/msai; the row holds workspace_id / workspace_name / return_to that were read from a SIGNATURE-VERIFIED token, so the page renders signed facts without the token ever touching the client. Expired or already-consumed handles are refused. The read does NOT consume the handle (POST /api/msai/connect does), so the page may verify before and after the OAuth hop. (2) Falls back to the pre-existing path: verify an MSai HS256 JWT from the body or the msai_sso_token cookie against MSAI_SSO_SECRET using the same code path as GET /api/sso/msai, plus exp/nbf. 401 on invalid or expired. Read-only: writes nothing, creates no user, issues no session. Never returns the stored sso_token to the client. GET /api/sso/msai [Public] MSai SSO landing — MarketingSecrets.ai opens the embedded app here as a full-page GET (/api/sso/msai?token=). Public entry point, BEFORE any OverSkill session exists. Verifies the token's HS256 signature, reads the SIGNED workspace_id/sub/email/intent/workspace_name/return_to claims, forwards the workspace as a ?workspace_id= param AND a partitioned cookie, and writes the workspace binding onto matching msai_link rows. PENDING_CONNECT_V1 (NEW) — on a connect launch the claims are ALSO persisted in msai_pending_connect keyed on the MSai `sub` + verified email (10 min TTL), NOT on a session or an opaque handle, because neither exists yet and both are destroyed by the OAuth bounce. Any later authenticated landing resolves the caller back to that row via msai_link.msai_user_id or the verified email. SSO_PROBE_V1 (NEW) — every landing persists one msai_sso_probe row recording token_present / token_verified / secret_configured / claims / outcome / destination, so a launch that fails upstream can be diagnosed from data instead of guessed at. INTENT_STASH_V1 (retained) — the opaque single-use handle path still runs for launches where the URL survives. GET /msai/connect/:token [Public] MSai path-token connect bridge. Catches /msai/connect/ and redirects to the dotless /msai/connect-token?ct= form. Tokens are now normally base64url-wrapped (dot-free) so they would resolve directly, but this bridge is retained as the ONLY thing that makes a raw dotted JWT reachable — the platform's SPA fallback 404s any dotted path as a file request. Transport only: never parses, verifies or trusts the token. POST /api/msai/connect/path-token [Public] CONNECT_TOKEN_V1 (read half). Verifies the HS256 connect token MSai signs into the RETURN PATH /msai/connect/. The token arrives base64url-WRAPPED (CONNECT_TOKEN_UNWRAP_V1) because the platform's static handler 404s any dotted path segment; a raw dotted JWT is also accepted. Checks signature vs MSAI_SSO_SECRET, iss='marketingsecrets.ai', aud='-connect', exp/nbf, and intent='connect'. Then requires an app session and requires VERIFIED_EMAIL_V1(session) === lower(token.email). Returns one of three statuses: session_required (valid token, no session), account_mismatch (valid token, wrong account), ready. Genuine token failures are 401. Read-only: writes nothing, mints no session, creates no user. GET /api/public/funnel [Public] PUBLIC (requires_auth:false): read-only view of a funnel by share token. Validates the token against the synthetic 'public-links' scope (exact match, reject revoked), then impersonates the owner_user_id from that row to assemble ONE PII-stripped response: funnel (incl. board_material, has_bg_image, and template_slug/template_name), active pages (each carrying page_type_name), active connections, sequence_counts, and per-page {content, active swipe_picks, active attachments, active comments}. NOTES-ONLY as of Sep 2026: page content now exposes ONLY `notes` (rich-text HTML) — headlines / copy_ideas / price_point were removed from the product and are no longer returned, so a shared board can't surface fields the owner can no longer see or edit. has_bg_image is a BOOLEAN only — the raw bg_image_key storage path is never exposed; the bytes come from /api/public/funnel-image?kind=board-bg. NEVER includes overskill_user_id, author_user_id, owner ids, or any email. 404 on unknown/revoked token. POST /api/msai/backfill-workspace [Public] One-time admin backfill: tag workspace-untagged funnels. Server-to-server only, fail-closed on X-MSai-Key (503 NOT_CONFIGURED when MSAI_AGENT_API_KEY is unset, 401 invalid_key on mismatch). SAFE BY DEFAULT: this endpoint MUTATES owner data, so it runs as a DRY RUN and only reports the plan unless the caller passes dry_run:false. Two strategies. 'assignments' (DEFAULT) applies an explicit {funnel_id: workspace_id} map — precise, and the only strategy that can put a board in the workspace it was actually created in. 'link_binding' applies the owner's stored msai_link.msai_workspace_id to all their untagged boards; that binding is the LAST-verified workspace and is frequently stale, so it can silently sweep personal standalone boards into a team workspace — use only with a dry run reviewed first. Both strategies MEMBERSHIP-PROVE the target against the owner's own workspaces_json and refuse otherwise. IDEMPOTENT: an already-tagged row is never touched or re-pointed, so re-running changes nothing. Reads system-wide (system context, reads only); each write is performed under the OWNING user's context. Logs every id changed. Returns {dry_run, strategy, updated, planned, skipped, changes[], skipped_detail[]}. GET /api/msai/agent/link [Public] MSai agent read: is this workspace CONNECTED to a Sales Funnels account, and to WHICH Sales Funnels workspace? Server-to-server only, same fail-closed X-MSai-Key guard as /api/msai/agent/funnels. Reads the EXPLICIT msai_workspace_link table only — never msai_link — so a mere SSO launch can never make a workspace look connected. NEW: data now carries workspace_ref (the linked app-side Sales Funnels workspace id) and workspace_name (its display name, read from the workspace row via the owning account), alongside the pre-existing msai_workspace_name. funnel_count counts the SAME set /agent/funnels returns: when workspace_ref is set that is every non-archived board filed in that SF workspace across all its members; for an older link with no workspace_ref it stays the account-wide count. 404 workspace_not_found when no link row exists. Never returns overskill_user_id. POST /api/msai/agent/unlink [Public] MSai agent write: DISCONNECT a workspace from its Sales Funnels account. Server-to-server only, same fail-closed X-MSai-Key guard as /api/msai/agent/funnels (503 NOT_CONFIGURED, 401 invalid_key). Deletes the msai_workspace_link row for {workspace_id} and NOTHING ELSE — no funnel, page, sequence_step, share or msai_link row is touched, so the account keeps every board and the existing embed/launch behavior is unaffected. After this the workspace falls back to the pre-existing per-funnel-tag scoping. Idempotent: unlinking an already-disconnected workspace returns 200 {ok:true,data:{removed:false}} rather than an error. msai_user_id is accepted for audit logging only and never gates the delete (the key is the credential). INTEGRATIONS ------------ GET /api/composio/connections - List Composio connections POST /api/composio/execute - Execute Composio action Body: { action, params, provider, alias? }. `alias` names ONE of the signed-in user's own connected accounts for that provider (e.g. "Work Gmail"); omit it to use their default account. ANALYTICS --------- POST /api/analytics/record - Record analytics event BACKGROUND JOBS --------------- POST /api/backend/queue-job - Queue background job GET /api/backend/job-status/{id} - Get job status AI FEATURES ----------- POST /api/ai/chat - AI chat completion POST /api/ai/object - Generate structured JSON (schema-constrained) POST /api/ai/vision - Image/OCR understanding (base64 image -> text/JSON) POST /api/ai/embedding - Generate embeddings KNOWLEDGE BASE -------------- POST /api/rag/upload - Upload document POST /api/rag/query - Query knowledge base GET /api/rag/documents - List documents DELETE /api/rag/documents/{id} - Delete document -------------------------------------------------------------------------------- For detailed OpenAPI specification: http://salesfunnels.com/api/openapi.json For interactive docs: http://salesfunnels.com/api-docs