Documentation

API reference.

Bearer-token REST, JSON request/response, versioned at /api/v1. Every checkbox in the builder is a JSON field; every AI capability is an endpoint.

A neat grid of rounded rectangles representing API endpoint groups, connected by thin diagonal lines

Quick links: Interactive reference (Scalar) · OpenAPI 3.1 spec · MCP server · Webhooks + embeds

Built for agents

The entire contract is two machine-readable URLs. Fetch them and an agent has every endpoint, parameter, status code, and form field, no scraping or guessing:

  • /api/v1/openapi.json - OpenAPI 3.1: every endpoint, method, query param, request body, and response.
  • /schema/form.schema.json - JSON Schema for the whole FormDefinition: questions, settings, Form Intelligence, and every response-page block + field, each with a plain-language description.

MCP clients reach all of it through the MCP server - every tool maps 1:1 to a /v1 endpoint. Everything below is that same contract, spelled out.

Quickstart

One curl from key to first call:

# 1. Mint an API key (with the scopes you need):
#    https://askery.app/dashboard/api-keys

# 2. List your forms:
curl -H "Authorization: Bearer ak_live_..." \
  https://askery.app/api/v1/forms

# 3. Generate a form from a brief:
curl -X POST -H "Authorization: Bearer ak_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: gen-2026-06-05-001" \
  https://askery.app/api/v1/forms/ai/generate \
  -d '{ "brief": "Five questions for an enterprise NPS survey.", "save": true }'

# 4. Pull the response payload:
curl -H "Authorization: Bearer ak_live_..." \
  "https://askery.app/api/v1/forms/<id>/responses?status=completed&limit=100"

Base URL + versioning

All endpoints live under https://askery.app/api/v1/…. We bump the prefix (/api/v2, etc.) when we break a contract; existing endpoints stay frozen as long as anyone uses them.

Authentication

Bearer-token, scope-gated. Mint a key at /dashboard/api-keys; the full key is shown once on creation (format ak_live_… in production). We store only a SHA-256 hash plus an 8-char prefix for identifying the key in the UI.

Authorization: Bearer ak_live_<...>

On every request the server returns:

  • 200/201/204 - success
  • 401 missing_credentials / invalid_credentials - header missing, key unknown, or revoked
  • 403 insufficient_scope - key is valid but missing the scope this endpoint requires

Scopes

Each key carries a set of scopes you choose at creation. Pick the narrowest set you need - a key meant for a BI pipeline should not have forms:write.

ScopeGrants
forms:readList, get, version history, intelligence config, analytics.
forms:writeCreate, update, delete, publish, archive, restore, duplicate, save code.
responses:readList, get, CSV/JSONL export.
responses:writeDelete responses.
ai:runInvoke generate, edit, decision-code generate (cost: AI credits).
intelligence:runRun the Decision Engine sandbox; regenerate AI outcomes.
webhooks:writeCreate, update, delete webhook subscriptions.
imports:runPull form definitions from external URLs.
workspace:readWorkspace identity, plan, credits, audit log.
secrets:writeList, set, delete workspace secrets referenced by connectors via {{secret.NAME}}. Values are never returned.

Legacy read / write scopes from the v1.0 read API still work - they expand to the full set of new scopes server-side.

Pagination

Cursor-based, Stripe style. Every list endpoint returns:

{
  "data": [ ... ],
  "pagination": {
    "limit": 50,
    "next_cursor": "eyJrIjoiMjAyNi0wNS0yNVQxMzoyNDoyOC44ODlaIiwiaSI6IjMxNDNlYmQ4LTcwODYtNGI1Zi1iYzE0LWQ1YTAwNWY0MzAzYiJ9",
    "has_more": true
  }
}

To fetch the next page, send back the next_cursor as ?cursor=…. When has_more is false, next_cursor is nulland you've reached the end.

Cursors are opaque (currently base64url JSON, may change). Don't decode them; just round-trip what we sent.

Idempotency

Mutations (POST / PUT / PATCH / DELETE) accept an Idempotency-Key header up to 256 chars. We fingerprint (method, path, canonical body) and store the response for 24h, so:

  • Same key, same body within 24h → replay the stored response (same status, same JSON).
  • Same key, different body → 409 idempotency_conflict. That's a client bug; silent replay would mask it.
  • Different key → fresh execution.

Use it on any operation a network blip could double: form creation, AI generation, webhook creation, response delete.

Errors

New endpoints (everything beyond the original three read routes) use a structured envelope:

{
  "error": {
    "code": "form_not_found",
    "message": "No form with id 'abc'.",
    "request_id": "req_abc123…",
    "hint": "Did you mean a draft? Check ?include_drafts=true.",
    "docs_url": "https://askery.app/docs/api/errors/form_not_found",
    "details": { ... }
  }
}

code is stable - branch on it. message is for logs and dev tools, not for end users. request_id goes back to support tickets and is in the X-Request-Id response header.

The two original read endpoints (GET /api/v1/forms and GET /api/v1/forms/{id}/responses) keep their legacy { error, message } shape for back-compat with the WordPress plugin and v1.0 customers.

What you can do

Twelve endpoint groups; one canonical schema. The interactive reference at /dashboard/developers/api-reference lets you call every endpoint live against your workspace.

Forms

  • List, create, get, JSON-merge-patch, full replace, delete
  • Duplicate, publish, archive, restore
  • Audit-grade version history with one-click rollback

AI

  • Generate - brief → FormDefinition (save inline or preview)
  • Edit - chat-style edit with multi-turn history; preview vs save
  • Ops - deterministic, 0-credit mutations via the EditOp protocol. Twelve ops cover the full builder surface: add_question, update_question, remove_question, move_question, set_theme, set_settings, set_title, set_description, add_connector, update_connector, remove_connector, and set_connector_hooks. Atomic: any failure rolls back the whole batch. POST to /v1/forms/{id}/ops with { ops: [...] }.

Responses

  • List with rich filters (status, email, since, until) + cursor pagination
  • Folded answers (answers: { [question_id]: value })
  • Optional cached AI outcome via ?include=intelligence
  • Streaming CSV and JSONL exports
  • Delete a single response

Form Intelligence + Decision Engine

  • Read intelligence config (mode, rules, decision code, sections, test personas)
  • Save Decision Engine code with validators (forbidden globals, hallucinated question ids, missing default export)
  • AI-generate Decision Engine code from a brief (IR extraction → render → critique)
  • Test code in the WASM sandbox against synthetic answers (0 credits)
  • Regenerate the cached AI outcome on a single response

Connectors - live API options + mid-form branching

A Connector is a named, reusable HTTPS call attached to the form. Two product surfaces use one primitive:

  • Dynamic options. A select-type question (single_select / multi_select / dropdown) sets optionsConnector.connectorId instead of (or in addition to) a static options[]. When the question renders, Askery POSTs the form state to your URL server-side, extracts an array from the response, and renders the items as options. Cascades: refreshOn: ["country"] re-fetches when those questions change.
  • Mid-form branching. Add an entry to settings.intelligence.connectorHooks[] with afterQuestionId, connectorId, and storeAs. After that question is answered, Askery fires the connector and stashes the result under ctx.<storeAs>. Later questions can branch on it via visibleIf; Decision Engine code reads it via ctx.<storeAs>.
  • Picture choices with images. A picture_choice question can also set optionsConnector - the image grid is loaded from your catalog API at fill time. Set connector.optionImage to an image URL template that reads each item via {{item.<field>}}, so a picture URL can be BUILT from a product id even when the API returns no image field: https://cdn.example.com/products/{{item.id}}.jpg. Or read a direct field with {{item.primary_image}}. If the template resolves to nothing (null / missing field), that option renders as a clean label-only tile - so ragged catalogs (some items have images, some don't) degrade gracefully.

Templates: {{answers.<question_id>}}, {{secret.<NAME>}}, {{ctx.<storeAs>.<path>}}, {{form.id}}, {{response.session}}, and {{item.<field>}} (only inside optionImage, resolved per option item). Secrets live in a workspace_secrets table (AES-GCM encrypted) and are never returned by the API.

Endpoint: POST /v1/forms/{id}/connectors/{cid}/test dry-runs the connector with sample answers - useful from your own builder UI to validate config before publishing. Connectors themselves are CRUD'd inline on the form definition (PATCH /v1/forms/{id} with merge-patch).

Customer endpoint contract: HTTPS only; private IPs blocked; signed with HMAC-SHA256 (Askery-Signature header); 5s timeout default; 1 MB response cap; 60s response cache by fingerprint. Audit log: connector_invocations (one row per attempt).

Workspace secrets

Store API keys, bearer tokens, and other credentials once at the org level instead of inlining them into form definitions. Connector templates reference them as {{secret.<NAME>}} (UPPER_SNAKE_CASE). Values are AES-GCM encrypted at rest and never returned by any endpoint - only names + timestamps are. Scope: secrets:write.

  • GET /v1/workspace/secrets - list { items: [{ name, created_at, updated_at }] }
  • POST /v1/workspace/secrets - body { name, value }; idempotent upsert
  • PUT /v1/workspace/secrets/{name} - body { value }; rotate by name
  • DELETE /v1/workspace/secrets/{name} - connectors still templating that name fail loudly on next invoke (audit row marks the missing secret)

Forms - PATCH extension

PATCH /v1/forms/{id} is an RFC 7396 JSON Merge Patch on the form definition, plus three top-level fields that live on the forms table (outside the JSONB definition): custom_css (form-wide injected CSS, ≤50 KB), logo_mode (org | custom | none), and logo_url (used when logo_mode="custom"). Pass null to clear custom_css or logo_url. Scope: forms:write.

Response page - block model + customisation

The page a respondent sees after submitting is fully customisable. Its layout is an ordered list of typedblocks on settings.responsePage.blocks[], rendered on top of a page-level theme. Both Form Intelligence modes (Smart Rules and Decision Engine) emit the same outcome shape, so the same templates and blocks work whichever backend produced the result.

Knobs on settings.responsePage:

  • template - preset name the page was seeded from. 14 named designs plus blank and custom: profile, score, magazine, actionplan, dashboard, receipt, letter, certificate, polaroid, timeline, terminal, brutalist, cinema, boutique. Switching the template REPLACES the current blocks with the seed.
  • blocks[] - ordered list of typed blocks (see catalog below). Empty array means use the legacy shape renderer for back-compat.
  • theme - page-level { bg, fg, accent, cardBg, border, bodyFont, headingFont, headingWeight, radius, maxWidth }. Fills in any field a block leaves unset.
  • showScore - auto (show iff intelligence.outputMode === "scored"),always, or never. auto is the right default; never suppresses the score even on scored forms.

Knob on settings.intelligence:

  • outputMode - scored (numeric 0-100 + dimensions + archetype), profile (archetype + traits + sections, NO score), or auto (codegen heuristic, defaulting to scored when in doubt). For descriptive / preference forms (personality tests, eater types, style profiles) set this to profileso the engine doesn't manufacture a meaningless score.

Block catalog. Each block has id (kebab-case, unique within the page), type, visible, an optional style override, and a type-specific content payload. The 15 block types:

  • archetype-hero - big identity title + tagline + optional eyebrow
  • score-dial - 0..100 dial bound to outcome.score
  • dimension-bars - horizontal bars bound to outcome.dimensions[]
  • trait-chips - pill cloud bound to outcome.traits[]
  • prose - paragraph; optional dropCap
  • prose-card - bordered card containing prose, optional left accent stripe
  • prose-card-row - 2..4 sibling prose-cards (3-up layout)
  • pull-quote - editorial quote with side rule + optional cite
  • action-list - 1..6 numbered next-step rows with optional links
  • tile-grid - 2..4 col stat tiles with label / value / sub
  • cta - primary + optional secondary button + note
  • share-row - twitter / linkedin / whatsapp / facebook / email / copy
  • image - standalone image with alt + caption
  • divider - line / dots / pure space
  • custom-html - sanitised HTML escape hatch

Every block field. The exact content shape per type - constraints, enums and defaults included. Bound = { source:"outcome"|"static", outcomePath?:string, text?:string }; ? = optional; =x = default; <=n = max length / value. (This is generated from the same Zod schema served at /schema/form.schema.json.)

archetype-hero  content: { title:Bound, tagline?:Bound, eyebrow?:string<=80 }
score-dial      content: { source:"outcome"|"static", value?:0..100, max:int 2..1000 =100,
                           label:string<=60 ="Overall match", showNumber:bool =true }
dimension-bars  content: { source:"outcome"|"static", dimensions?:[{label<=80, value:0..100}]<=12,
                           maxValue:int 2..1000 =100, showValues:bool =true }
trait-chips     content: { source:"outcome"|"static", traits?:[string<=60]<=20,
                           label:string<=80 ="YOUR TRAITS", layout:"outline"|"filled"|"subtle" ="outline" }
prose           content: { heading?:string<=120, body:Bound, dropCap:bool =false }
prose-card      content: { heading?:string<=120, body:Bound, accentBar:bool =true }
prose-card-row  content: { columns:int 2..4 =3, cards:[{heading<=120, body<=800, accent?<=40}] (2..4) }
pull-quote      content: { quote:string 1..400, cite?:string<=80 }
action-list     content: { heading:string<=120 ="Here's what to do next",
                           steps:[{title<=120, sub?<=240, done:bool=false, href?<=500}] (1..6) }
tile-grid       content: { columns:int 2..4 =3, tiles:[{label<=60, value<=60, sub?<=120, accent?<=40}] (2..12) }
cta             content: { primaryLabel:string 1..60, primaryUrl:string<=500,
                           primaryStyle:"solid"|"outline"|"ghost" =solid,
                           secondaryLabel?<=60, secondaryUrl?<=500,
                           secondaryStyle:"solid"|"outline"|"ghost" =outline, note?<=160 }
share-row       content: { providers:["twitter"|"linkedin"|"whatsapp"|"facebook"|"email"|"copy"] (1..6),
                           label:string<=80 ="Share your result" }
image           content: { url:string<=2048, alt:string<=200 ="", caption?<=240,
                           position:"full"|"centered"|"left"|"right" =full }
divider         content: { kind:"line"|"dots"|"space" =line, spacing:"none"|"compact"|"comfy"|"spacious" =comfy }
custom-html     content: { html:string<=4000 }   // sanitized: script tags + on* handlers stripped

Style override (per block). { bg, fg, accent, bodyFont, headingFont, paddingY, align, maxWidth }. Empty / omitted = inherit the page theme. Set paddingY to none / compact / comfy / spacious; align to left / center / right; maxWidth to narrow / default / wide / full.

Outcome binding. Many block contents take a string field with shape { source: "outcome"|"static", outcomePath?, text? }. With source="outcome" the renderer dot-paths into the AI outcome (e.g. "archetype", "tagline", "traits[0]") and falls back to text if absent. With source="static" the literal text is used.

Ops for editing the response page. Six new deterministic ops on POST /v1/forms/{id}/ops (scope forms:write):

  • set_response_template - { op, template }. Seeds blocks + theme from a preset; replaces existing blocks.
  • set_response_theme - { op, patch }. Deep-merges a patch onto responsePage.theme.
  • add_block - { op, block, position: { after | before | atStart | atEnd } }. Inserts a block.
  • update_block - { op, id, patch }. Deep-merges a patch onto an existing block (style / content / visible).
  • remove_block - { op, id }.
  • move_block - { op, id, position }. Same position grammar as add_block.

The same ops are reachable via the AI editor (POST /v1/forms/{id}/ai/edit, scope ai:run) - Claude / Gemini will issue them when a natural-language brief asks for response-page changes ("switch to the magazine template", "hide the score", "add a green accent to the action plan"). MCP clients drive the same surface through askery_apply_ops.

Worked example - kill a fake score (Sifter pattern). For a preference-collection form like an eater-type quiz, flip both the engine output mode AND the page template in one PATCH:

PATCH /v1/forms/{id}
Content-Type: application/merge-patch+json
Authorization: Bearer ak_…

{
  "settings": {
    "intelligence": { "outputMode": "profile" },
    "responsePage": {
      "template": "profile",
      "showScore": "never"
    }
  }
}

Then ops to fine-tune the seeded blocks (rebrand the cards, add a share row, swap the CTA):

POST /v1/forms/{id}/ops
Authorization: Bearer ak_…

{
  "ops": [
    { "op": "set_response_theme",
      "patch": { "accent": "#7c3aed",
                 "headingFont": "Playfair Display" } },
    { "op": "update_block",
      "id": "hero",
      "patch": { "style": { "align": "center" } } },
    { "op": "add_block",
      "block": {
        "id": "share",
        "type": "share-row",
        "visible": true,
        "content": { "providers": ["twitter","linkedin","copy"],
                     "label": "SHARE YOUR PROFILE" }
      },
      "position": { "atEnd": true } },
    { "op": "update_block",
      "id": "cta",
      "patch": { "content": { "primaryLabel": "Create my profile →",
                              "primaryUrl": "https://sifter.app/start" } } }
  ]
}

Worked example - build a result page from scratch. Start from the empty blank canvas and add blocks in order. Outcome-bound fields (title, the score, sections.*.body) fill from each respondent's AI result; static fields render verbatim. One request:

POST /v1/forms/{id}/ops          (scope forms:write)
Authorization: Bearer ak_…

{
  "ops": [
    { "op": "set_response_template", "template": "blank" },
    { "op": "set_response_theme",
      "patch": { "bg": "#0f1226", "fg": "#eef0fb", "accent": "#7c8cff",
                 "headingFont": "Georgia", "radius": "14px" } },

    { "op": "add_block", "position": { "atEnd": true },
      "block": { "id": "hero", "type": "archetype-hero", "visible": true,
        "content": {
          "eyebrow": "YOUR RESULT",
          "title":   { "source": "outcome", "outcomePath": "archetype", "text": "Your type" },
          "tagline": { "source": "outcome", "outcomePath": "tagline" } } } },

    { "op": "add_block", "position": { "atEnd": true },
      "block": { "id": "score", "type": "score-dial", "visible": true,
        "content": { "source": "outcome", "label": "Overall match", "showNumber": true } } },

    { "op": "add_block", "position": { "atEnd": true },
      "block": { "id": "why", "type": "prose-card", "visible": true,
        "content": { "heading": "Why this is you",
          "body": { "source": "outcome", "outcomePath": "sections.why.body", "text": "" } } } },

    { "op": "add_block", "position": { "atEnd": true },
      "block": { "id": "next", "type": "action-list", "visible": true,
        "content": { "heading": "Your next steps",
          "steps": [ { "title": "Do this first", "sub": "why it matters" },
                     { "title": "Then this" } ] } } },

    { "op": "add_block", "position": { "atEnd": true },
      "block": { "id": "cta", "type": "cta", "visible": true, "style": { "align": "center" },
        "content": { "primaryLabel": "Start free", "primaryUrl": "https://askery.app/signup",
                     "primaryStyle": "solid" } } },

    { "op": "add_block", "position": { "atEnd": true },
      "block": { "id": "share", "type": "share-row", "visible": true,
        "content": { "providers": ["twitter", "linkedin", "copy"], "label": "Share your result" } } }
  ]
}

# then publish so respondents land on it:
POST /v1/forms/{id}/publish      (scope forms:write)

Every op is validated against the canonical schema and the whole batch is atomic: any op that fails rolls the set back and the response names the offending op (by index) and why. The exact same batch works through the MCP askery_apply_ops tool, so an agent can build this page from one natural-language brief.

Cached AI outcomes from before the change can be regenerated on demand: POST /v1/forms/{id}/responses/{rid}/regenerate-result.

Import

Point at any form URL - Google Forms is parsed deterministically; everything else falls through to the LLM mapper. Returns a validated FormDefinition + an import report flagging fields that couldn't map cleanly.

Prefill links

Compose ?question_id=value URLs without manual encoding. Validates every key against the stored form, so a typo returns 400 instead of silently dropping.

Webhooks

  • Full CRUD on subscriptions (URL + events + active flag)
  • Secret is returned once on creation (mirroring API key behaviour)
  • Per-webhook delivery log with status, latency, and error attempts

Workspace

  • GET /workspace - whoami: id, name, plan, credit balance, plan renewal date
  • GET /usage - recent credit ledger entries
  • GET /workspace/audit-log - form creates, member invites, key issuances, etc.
  • GET /forms/{id}/analytics - views, starts, completions, partials, funnel, per-question summaries

Limits + plan gates

  • Bodies cap at 1 MB (forms cap at ~200 KB).
  • Free plan: 1 webhook subscription. Pro+: unlimited.
  • AI endpoints debit workspace credits per the same usage page schedule as the dashboard. Out of credits returns 402 insufficient_credits with details: { required, balance, reason }.

Webhooks vs API

Use webhooks for real-time delivery - every submission posts a single HMAC-SHA256-signed JSON payload to your URL, retried on failure. See /developers for the payload shape and signature verification recipe.

Use the API for everything else: cron-driven syncs, on-demand fetches, BI pipelines, internal admin tools, and anything that needs to mutate (create / edit / delete).

MCP - drive Askery from any AI agent

The MCP server is a Model Context Protocol bridge over the same REST API. Wire it into Claude Desktop, Cursor, or any MCP-capable client and your agent can operate Askery directly: build forms by name, fetch responses, codegen Decision Engines, manage webhooks. Same scopes, same audit trail, same rate limits - agents are first-class clients, not a separate world.

Building something specific? Email support@askery.app.