Deckr Machine-to-Machine API (v1)

Platforms submit survey data + a brief over HTTP and retrieve a finished,

branded deck. The data is a file (multipart) or the data itself as JSON

— the parsed tables contract every cross-tab family reduces to, or respondent

rows — so a platform with a live data store never writes an export (§141). The

API is additive to the browser /generate flow — it does not change it. Generation is asynchronous: a

submit returns immediately with a job id, the deck is built off the request

thread (so it is never bounded by the 120 s Gunicorn timeout), and the result is

delivered via a webhook callback and/or polling, behind a time-boxed signed URL.

Base path: /api/v1


Authentication

Every client has one API key, issued by an admin. Send it as a bearer token:


Authorization: Bearer dk_<key_id>_<secret>

(X-API-Key: dk_... is also accepted.) Keys are stored hashed (bcrypt) — the

plaintext is shown only once at issue/rotate. A forged, malformed, revoked, or

inactive-account key returns 401 and creates no job. Every job/artifact lookup

is strictly scoped to the resolved client.

Admin key management (session-authenticated admin only):


POST /admin/clients/<client_id>/api-key      action=issue|rotate|revoke

issue/rotate return {api_key, webhook_secret, key_id} once. revoke

deactivates the key without affecting the client's browser login.

Rate limit: 60 per hour per client key (in addition to the global default).


Submit a deck


POST /api/v1/decks
Authorization: Bearer dk_...
Content-Type: multipart/form-data        (a file)
Content-Type: application/json           (the data itself — see "Intake without a file")

Every field below is read the same way from either body: as a multipart form

field, or as a top-level key of the JSON object (a string exactly as the form

would carry it, or the typed value — an object/list for the JSON-valued fields,

a boolean for a flag, a number for max_slides/sig_confidence). Uploaded

assets (template_file, logo_file, reference_deck, market_files) are

multipart-only.

fieldrequirednotes
file✓ (multipart)survey data: raw CSV / .xlsx / SPSS .sav / Decipher, Bryter, OnePoll, proportion or generic-banner cross-tab export / Qualtrics export
tables / rows (+ columns)✓ (JSON)the data itself — exactly one of the two. See "Intake without a file"
study_typeone of the six study types (label or key); default Post-event research. Unknown → 400. A cross-tab export or a tables body has no KPI columns to map — send Other quantitative, as the browser does for a tables upload
project_idignored on an org-level key (the effective profile is the org's). On a project-scoped key it must equal the key's project or is a 403; the key's project is assigned either way
event, audience, key_message, tonebrief text
primary_color, secondary_color, fontbranding (defaults to the client profile). '' is the Auto sentinel (§96): nothing chosen, the reference deck / stored house style decides
sig_style, sig_confidence, deck_profilethe significance marker (arrows/redfill), the stated confidence (90/95/99) and the deck shape (full/pr_summary/charts) for this run; each folds into conventions and beats the stored house style. Omit for Auto
conventionsJSON object of house-convention overrides for this run (every key /org/settings stores, e.g. {"pie_style": "pie", "banner_cuts": "Country"}); validated, malformed → 400
banner_template, save_banner_templateload a saved banner definition by name (an explicit banner_cols wins) / save this run's banner under a name
agency, method, fieldwork_dates, sample_note, objectivesbrief metadata
filter_col, filter_valoptional sub-sample cut
chart_overridesJSON map {column name: chart type} pinning a question's chart (Loop 13), per run only (§138: there is no org-level pin); an invalid/incompatible pin falls back to the automatic chart. Malformed JSON → 400
max_slidesslide-count target, clamped to 5..150. Overrides the org's saved cap; omit to use the saved cap (or the 40 default)
driver_analysis1/true/on/yes — opt in to key-driver analysis (derived importance + priority quadrant). Omit → no driver slide
cutsJSON list of {label, filters:[{col,val}]} — one deck per cut, delivered as a ZIP (Loop 12)
analysis_planJSON list of {label, filters:[{col,val}]} — the comparisons you set out to run; framed plan-vs-reality in the narrative (Loop 18)
hypothesesJSON list of directional statements tested against significance-aware data (Loop 18 — see below)
profileJSON StudyProfile — a reviewed/edited analysis plan (Loop 25 — see below). Validated against this upload's question inventory; stale or fabricated references → 400
planpreview — return the derived analysis plan as 200 {analysis_plan} without enqueuing a job (Loop 25 — see below)
template_file, logo_fileoptional branding assets
reference_deckoptional finished house deck (.pptx) — house conventions (sig style, base-note wording, chart decimal places) are mined from it and layered BENEATH any profile/per-run conventions (an explicit setting always wins; unmineable/ambiguous → code defaults; fail-open)
analyst_contextan optional analyst brief for the narrative — reaches the prompt as a guarded, never-cited block, kept out of the fact-check
output_formatdeck (default) · tables (the banner cross-tab Excel book only, no deck, kind: xlsx) · both (deck + book as one ZIP) — Loop U11/U12
banner_colsthe banner for output_format=tables/both and for the significance splits: a JSON list or comma-separated column names. Omit → the demographic banner is proposed automatically
sig_modevs_total (default: every cut against Total) or pairwise / letters / col_vs_col (column-vs-column letters within each bank) on the tables book
segment_portrait1/true/on/yes — render a detected segment fan-out as Total-vs-segment pen-portrait decks (DEFERRED #10). Omit → the segment fan-out is a per-segment filtered deck
recommendation_review, reviewed_recommendationsopt in to the per-recommendation review state (1/true/on/yes); reviewed_recommendations is all, or the 1-based positions marked reviewed (1,3 or [1,3]). Every other recommendation renders a "DRAFT — PENDING REVIEW" badge. Malformed → 400
market_files (or files[]), market_labelsmulti-file market intake (multipart only): further per-market data files, ONE deck per file (+ the primary file) as a ZIP under this one brief. market_labels is a JSON list or comma-separated labels, primary first, one per file (count mismatch → 400; omit → filename-derived). Cannot be combined with cuts; output_format must be deck
callback_urlwebhook target (must be a public http(s) URL; defaults to the client profile)
Idempotency-Key (header) or idempotency_keyduplicate submits with the same key return the same job

Responses

A multi-market file (a detected country/market column with ≥2 qualifying

markets) produces one global deck + one per market, delivered as a single ZIP

artifact (Loop 9). Otherwise the artifact is a single .pptx.

Intake without a file (§141)

A JSON body carries the data as one of two keys and the brief as the other

top-level keys. It is converted, at the route, into the bytes the pipeline

already reads, so a JSON post and the export it came from meet the same code

with the same argument — proven byte-identical at the built deck for all five

cross-tab families and for a raw CSV (test_api_door.py).

tables — the parsed contract. Every cross-tab family Deckr reads

(Decipher, Bryter, OnePoll, proportion, generic banner) is parsed into ONE

internal contract and handed to one function; posting the contract skips the

parse entirely. It is an object:


{"tables": {
   "meta":   {"family": "myplatform", "project": "Wave 3"},
   "tables": [
     {"code": "Table 1", "question": "Q1. Which brand do you buy most often?",
      "subtitle": "",
      "columns": [{"index": 1, "label": "Total", "letter": "z", "group": "", "base": 1002},
                  {"index": 2, "label": "Male",  "letter": "a", "group": "Gender", "base": 498},
                  {"index": 3, "label": "Female","letter": "b", "group": "Gender", "base": 504}],
      "total_index": 1,
      "rows": [{"label": "Brand A", "kind": "pct", "values": [54.8, 49.1, 60.3], "sig": ["", "", "a"]},
               {"label": "Brand B", "kind": "pct", "values": [45.2, 50.9, 39.7], "sig": ["", "b", ""]},
               {"label": "Mean",    "kind": "stat","values": [1.45, 1.51, 1.40]}]}
   ],
   "freetext": {"Q9. Anything else?": ["Great service", "Too expensive", "..."]}
 },
 "study_type": "Other quantitative", "event": "Wave 3", "audience": "UK adults"}
keyrequirednotes
metaan object (may be empty). family names your platform and is recorded on the job (source_family) where a sniffed family would be; anything else is carried through
tables[]one entry per question; non-empty
questionthe question text; subtitle (a grid item's row) and code are optional
columns[]the banner: index (integer, unique), label; optional letter (the sig-test letter — Total is conventionally z), group (the bank a cut belongs to; '' for Total), base, base_wtd, small_base
total_indexthe index of the Total column; defaults to the first column
rows[]label, values (one number or null per column, percentages as 0–100), optional kind (pct default · num · stat · net · count · mean · base), indent, sig (one letter-string per column: the columns this cell is significantly HIGHER than — decoded, never recomputed, exactly as an agency's own letters are)
sample_compositiontrue on a table that is a demographic block — routed to the sample-profile slide, as OnePoll's are
freetext{question: [responses]} — open ends, rendered as pull-quote slides and coded when ≥8 substantive responses

Anything a parser would have filled is defaulted (subtitle, code, letter,

group, bases, kind, sig), so a minimal table is question + columns +

rows. Structural defects are a 400 naming the path (`tables[3].rows[1].values

must be a list with one entry per column (3)`). The same plausibility gate every

family passes (§127) then asks whether the tables are QUESTIONS — a body of

one-row answer options is refused in the same words a mis-parse is. Significance

is never recomputed on a contract; a ▲/▼ marker is a decoded letter.

rows — respondent records. A non-empty list of objects (one per

respondent; the header is the union of keys in first-seen order) or of arrays

with columns. null → blank, numbers and strings as written. **Column order

is deck order** (the slide sequence and the verification appendix's column

index follow it), and a JSON serialiser may sort object keys — so send

columns to pin the order (with objects or arrays); measured: the same rows

posted with sorted keys and no columns build a deck with two slides

swapped, with columns the export's own deck. They travel as

a CSV under the name rows.csv and take the raw-data path exactly as an

uploaded CSV — question typing, KPI detection, weighting (weight column),

cuts, markets, everything. Not carried: variable labels and value labels (a

CSV has none). A platform whose questions are coded (Q4_1 with labels held

elsewhere) should send the .sav it already has as a file, or send worded

headers; the labels gap is recorded, not hidden.

plan=preview, Idempotency-Key, profile, conventions and every other field

work on a JSON body exactly as on multipart (an object-valued profile /

conventions / cuts / chart_overrides / hypotheses / analysis_plan /

reviewed_recommendations / banner_cols is accepted as the object).

API-only fields (§139)

The browser's generate form and Settings page were simplified on 2026-09-18:

mining, plan review and the stored house profile now own what those screens

used to ask. Every capability stays on this door. The following are therefore

API-only — accepted here, no longer carried by any browser control:

fieldwhat the browser does instead
tonenot asked; the register comes from the mined headline_style / summary_register
primary_color, secondary_color, font, sig_style, sig_confidence, deck_profile, agencythe org owns them (Settings › Identity / Your house style); plan review confirms preferences
reference_deck (per-run mine)the house-style panel on step 1 and Settings › Teach from a deck store the mine on the profile; a per-run mine that REPLACES the stored layer for one deck is API-only
hypotheses, analysis_plan (Loop 18)no browser door until a re-run screen exists — a first-time user cannot name columns and groups before a deck exists
cuts, chart_overrides as raw JSONthe fan-out picker and the chart picker write both keys; hand-written JSON is API-only
banner_template, save_banner_templatenot offered; banner_cols is the one door in the browser
conventions JSON (incl. banner_cuts, decimal_places, sort_ordinal, market_floor, market_min_base)learned keys are corrected one at a time under Settings › Your house style; per-study banner_cuts is the plan-review cut slot

Org-level fields with no browser control any more, still resolved from the

org profile on both doors and settable by POST /org/settings

(form-encoded, Owner/Admin session): kpi_columns, baseline_scores,

wave_label, max_slides, narrative_style. max_slides also remains a

per-run field above.

The org's saved configuration applies here exactly as it does in the browser.

max_slides, wave_label, baseline_scores, conventions, kpi_columns and

narrative_style are resolved from the org (then project) profile by one shared

function, so the same org submitting the same file gets the same deck through

either door. Any per-run field above still wins over the saved value. (Before

this fix the API silently ignored three of them.) chart_overrides is **per-run

only**: the org column was never written by anything and a column-name pin is

survey-specific, so it is not read (§138). **The org's stored template and logo

apply on this door too** (§141): an asset uploaded with the job wins, else the

effective (org → project) profile's stored asset, as in the browser. Before §141

the single-file API branch read only the upload, so the same org got a

template-less deck through the API.

Hypotheses & analysis plan (Loop 18)

Supply hypotheses to have the deck **test directional statements against the

real, significance-tested data and report each as confirmed / refuted /

inconclusive / untestable** — woven into the governing thought and executive

summary, and returned verbatim on narrative.hypotheses. Each hypothesis is a

structured object:


[
  {"label": "Decision-makers advocate more than end-users",
   "metric": "nps",
   "cut": "Role",
   "group_a": "Decision-maker",
   "group_b": "End-user",
   "expect": "a_gt_b"}
]
fieldnotes
labelthe directional statement (shown verbatim)
metricnps · satisfaction · importance · return_intent (the proportion compared: promoters for NPS, top-2-box otherwise)
cutthe column to split on (the comparison dimension)
group_a, group_bthe two values of cut being compared
expecta_gt_b · b_gt_a · differs (default differs)

**Verdict rules (honest by construction — the verdict is computed in Python from

significance.z_test_two_proportions, never by the model):**

Slicing reuses the multi-cut primitive (no re-implementation); a malformed

hypotheses / analysis_plan payload is a clean 400. Supplying neither leaves

the narrative byte-for-byte the standard (Loop 15) output.

B2B register

When audience denotes a business / decision-maker audience (e.g. "B2B

decision-makers", "IT buyers", "procurement"), the narrative register shifts

to commercial B2B framing (buyers, accounts, pipeline, cost-to-serve) — a

prompt-only change. The figures are identical; only the language differs. A

consumer audience is unaffected.

Analysis plan preview & researcher-edited profiles (Loop 25)

Every analysed upload gets a derived analysis plan (study type, decision

question, headline KPIs, section arc, and a per-question disposition: content

/ profile_composite / appendix / suppress). By default this is zero-touch:

submit the file, the derived plan steers the deck (DECKR_PROFILE defaults ON;

set 0 to fall back to the rules-based pipeline).

Preview without committingplan=preview analyses the file, derives the

plan and returns it synchronously; no job is enqueued, nothing is persisted:


POST /api/v1/decks   (multipart: file=…, study_type=…, plan=preview)
→ 200 { "analysis_plan": { "summary": {...}, "questions": [...], "profile": {...} } }

disposition counts and human-readable lines ("N screening questions → one

sample-profile slide", "M questions → appendix"), warnings.

disposition, base, response rate).

drop a KPI, retitle a section) and POST it back as the profile field on the

real submission. It carries an _inventory_sig tying it to this exact

upload + filter.

Submitting an edited profileprofile=<json> on a normal submission

makes the edited plan steer the deck (**per-run wins over a fresh

derivation**). It is validated against the upload's question inventory exactly

like a derived plan — the researcher can *correct*, not *fabricate*:

column, a stale plan) → 400 naming the ghost references

(every suppression is listed on the verification appendix, never silently gone)

Derive the preview with the same study_type / filter_col / filter_val

you will submit with — the signature covers the filtered inventory. Supplying

no profile uses the derived plan directly (the zero-touch platform path);

DECKR_PROFILE=0 + no profile is today's rules-based pipeline byte-for-byte.


Poll job status


GET /api/v1/decks/<job_id>
Authorization: Bearer dk_...

Re-run a study with a fresh file


POST /api/v1/decks/<job_id>/rerun
Authorization: Bearer dk_...
Content-Type: multipart/form-data       file=<the new wave's data>

The original job's stored brief — study type, column overrides, conventions,

analysis plan, chart pins, project — is reproduced verbatim; only the data file

is new (the next wave of a tracker, a re-cut of the same questionnaire). A new

async job is enqueued on the SAME project and the effective profile is

re-resolved. The re-run derives its OWN wave baseline from its own file (§63):

a baseline the original run detected is never inherited, a baseline the caller

supplied is kept. The narrative is re-generated; the ORIGINAL deliverable's

structure is re-applied where the questionnaire matches (§81), so the two decks

read as one deliverable re-cut.

Optional scalar overrides (form fields; each WINS over the stored value):

event, study_type, audience, key_message, tone, primary_color,

secondary_color, font, agency, method, fieldwork_dates, sample_note,

objectives, analyst_context, wave_label, market_label. Also callback_url

and Idempotency-Key/idempotency_key, as on submit.


Mining as a service — the house style through the API (§141)

Deckr learns an agency's house conventions from a finished deck of theirs (the

significance marker, the base-line wording, decimal places, chart sort order,

the deck's shape, its palette, register …), stores what the deck taught on the

org's profile (§123) with a tier per convention (rule / preference / device /

ask — §121), and asks the researcher only what the deck could not settle

(§124). These routes expose that exchange so a platform renders the questions

in its own interface and posts the answers back. They call the browser's own

seams and decide nothing of their own.


GET /api/v1/style                        the block the stored profile teaches
POST /api/v1/style                       multipart: reference_deck=<.pptx>  → mine it now, store, return the block
POST /api/v1/style/confirm               JSON: {"set": {key: value}, "clear": [key]}  → answers

All three take an optional project_id (query, form or JSON) naming one of the

org's OWN projects — the client profile whose departures from the house you are

teaching or confirming; omit it for the house. A project-scoped key is

pinned to its project (a project_id is ignored). Another org's project is a

404. API access is an Agency-plan entitlement here as on /decks; a mine or

an answer spends no deck quota.

The block{"house_style": {...}}:


{"questions": [
   {"key": "sig_style", "topic": "Significance on charts", "tier": "preference",
    "evidence": "Your chart slides mark significant differences with arrows (7 of 9 chart slides).",
    "n": 7, "total": 9, "profile": "house", "stale": false,
    "options": [
      {"label": "Arrows (▲▼)",   "set": {"sig_style": "arrows"},  "kind": "deck",    "current": true},
      {"label": "Shaded cells",  "set": {"sig_style": "redfill"}, "kind": "alt",     "current": false},
      {"label": "Deckr's default", "set": {"sig_style": "arrows"}, "kind": "default", "current": false}]},
   {"key": "footer_base", "topic": "Base line wording", "tier": "chosen", "...": "...",
    "chosen": {"value": "Base: {who} (n={n})", "label": "…"},
    "options": [{"label": "Let the deck decide again", "clear": ["footer_base"], "kind": "withdraw"}]}
 ],
 "applied":  [{"key": "decimal_places", "topic": "Decimal places", "line": "Decimal places: none (12 of 12 charts)"}],
 "source":   {"name": "house.pptx", "mined_at": "2026-09-18T10:02:11Z", "stale": false},
 "counts":   {"questions": 6, "applied": 9, "answered": 1},
 "profile":  0,
 "mined":    true}
fieldmeaning
questions[]what the deck could not settle, one per convention: key (the convention), topic (plain words), tier (preference — applied, confirm it or pick an alternative · device — not applied, an empty slot the default holds until filled · ask — not applied, the instances the deck showed are offered · chosen — already answered), evidence (the sentence, in the deck's own numbers), n/total (the count over the population), options[]
options[]each carries the exact payload that answers it: set ({key: value}) or clear ([key]); kind is deck (what the deck showed), alt (a settable alternative), default (Deckr's default), withdraw (revert to the mine); current marks what applies now
applied[]the rules (≥80% of the population, ≥3 instances) applied silently — listed, never asked
sourcethe teaching deck; stale when it was mined by an older Deckr (still applied, re-POST the deck to refresh)
profile0 for the house, else the project id
mined / warningon a POST: whether the deck could be read; the warning when it could not (builds use the saved settings)

Answering — post an option's payload back, several at once:


POST /api/v1/style/confirm
{"project_id": 12, "set": {"sig_style": "redfill", "sig_confidence": 99}, "clear": ["sort_ordinal"]}

Each set entry becomes a CHOSEN row: it beats every later mine of the same key

(the row keeps the deck's evidence beside it) and applies to every deck this

profile builds, through either door, exactly as a Settings save or the plan-

review click would. clear withdraws a choice so the row reverts to what the

deck showed. A value the convention validator refuses (sig_confidence: 97, an

unknown key) is a 400 and writes nothing. The refreshed block is returned.

Nothing on these routes recomputes significance, changes a tier, or mines

differently from the browser.


Download

download_url is a time-boxed, HMAC-signed URL (default TTL 1 h via

DECKR_ARTIFACT_TTL). The URL itself is the credential — like an R2 presigned

URL — so no API key is needed to fetch it, but it cannot be forged, it expires,

and job ids are unguessable so one client cannot enumerate another's deck.


GET /api/v1/decks/<job_id>/download?expires=<ts>&sig=<hmac>

When R2 is configured the route 302-redirects to an R2 presigned URL; otherwise

it streams the locally-persisted artifact. Expired or forged signature → 403.


Webhook callback

On a terminal state, Deckr POSTs to the client's callback_url:


{
  "event": "deck.completed",            // or "deck.failed"
  "job_id": "…",
  "status": "done",                     // or "failed"
  "signed_url": "https://…/download?…", // present when done
  "error": "…",                         // present when failed
  "timestamp": 1733000000,
  "nonce": "…"
}

Headers:


X-Deckr-Signature: t=<timestamp>,v1=<hmac_sha256>
X-Deckr-Event: deck.completed

Verify the signature with your webhook_secret:


import hmac, hashlib
def verify(secret, header, raw_body):
    parts = dict(p.split('=', 1) for p in header.split(','))
    signed = f"{parts['t']}.".encode() + raw_body          # timestamp + raw body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts['v1'])

Reject stale timestamps and remember the nonce to detect replays.

Delivery semantics

(DECKR_WEBHOOK_MAX_ATTEMPTS, default 4).

polling.

(incl. 169.254.169.254) / private / reserved addresses is refused at

registration (400) and again at send time.


Job lifecycle


queued ──claim──▶ processing ──▶ done   ──▶ webhook (deck.completed)
                              └──▶ failed ──▶ webhook (deck.failed)

The input file + brief are persisted on submit, so a worker that dies mid-build

leaves the job recoverable: on startup any processing job is requeued and the

build re-runs idempotently from the stored input.


Integration flow

1. Admin issues the platform an API key + webhook secret; the platform registers

its callback_url.

2. Platform POST /api/v1/decks with the survey export → stores the job_id.

3. Platform receives the webhook (verifies the signature) or polls

GET /api/v1/decks/<job_id>.

4. Platform downloads the deck from the signed URL before it expires.


Environment variables

varpurpose
DECKR_PUBLIC_BASE_URLabsolute base URL used in webhook signed links (worker has no request context)
DECKR_ARTIFACT_TTLsigned-URL lifetime in seconds (default 3600)
DECKR_WEBHOOK_MAX_ATTEMPTSwebhook retry budget (default 4)
DECKR_WEBHOOK_TIMEOUTper-attempt timeout in seconds (default 5)
DECKR_WEBHOOK_BACKOFFbackoff base in seconds, *2^attempt (default 0.5)
DECKR_API_WORKERset 0 to disable the in-process async worker (tests drive jobs synchronously)
DECKR_PROFILEdynamic study profiling (Loop 25). Default ON; set 0/false/off to fall back to the rules-based disposition engine. Any profiling failure also falls back — today's pipeline byte-for-byte
R2_ACCOUNT_ID / R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY / R2_BUCKET_NAMEartifact persistence + presigned URLs (falls back to local storage when unset)

Regression gates: python3 test_api_endpoint.py (15 cases incl. adversarial/security), python3 -m pytest test_api_config_parity.py (§70), python3 -m pytest test_api_door.py (§141 — JSON intake, the style routes, the template fix, the status fields, and the lock that this document names every field the route reads).

See plans Back to home