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
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).
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.
| field | required | notes |
|---|---|---|
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_type | one 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_id | ignored 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, tone | brief text | |
primary_color, secondary_color, font | branding (defaults to the client profile). '' is the Auto sentinel (§96): nothing chosen, the reference deck / stored house style decides | |
sig_style, sig_confidence, deck_profile | the 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 | |
conventions | JSON 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_template | load 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, objectives | brief metadata | |
filter_col, filter_val | optional sub-sample cut | |
chart_overrides | JSON 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_slides | slide-count target, clamped to 5..150. Overrides the org's saved cap; omit to use the saved cap (or the 40 default) | |
driver_analysis | 1/true/on/yes — opt in to key-driver analysis (derived importance + priority quadrant). Omit → no driver slide | |
cuts | JSON list of {label, filters:[{col,val}]} — one deck per cut, delivered as a ZIP (Loop 12) | |
analysis_plan | JSON list of {label, filters:[{col,val}]} — the comparisons you set out to run; framed plan-vs-reality in the narrative (Loop 18) | |
hypotheses | JSON list of directional statements tested against significance-aware data (Loop 18 — see below) | |
profile | JSON StudyProfile — a reviewed/edited analysis plan (Loop 25 — see below). Validated against this upload's question inventory; stale or fabricated references → 400 | |
plan | preview — return the derived analysis plan as 200 {analysis_plan} without enqueuing a job (Loop 25 — see below) | |
template_file, logo_file | optional branding assets | |
reference_deck | optional 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_context | an optional analyst brief for the narrative — reaches the prompt as a guarded, never-cited block, kept out of the fact-check | |
output_format | deck (default) · tables (the banner cross-tab Excel book only, no deck, kind: xlsx) · both (deck + book as one ZIP) — Loop U11/U12 | |
banner_cols | the 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_mode | vs_total (default: every cut against Total) or pairwise / letters / col_vs_col (column-vs-column letters within each bank) on the tables book | |
segment_portrait | 1/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_recommendations | opt 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_labels | multi-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_url | webhook target (must be a public http(s) URL; defaults to the client profile) | |
Idempotency-Key (header) or idempotency_key | duplicate submits with the same key return the same job |
Responses
202 Accepted — { "job_id": "...", "status": "queued", "status_url": "..." }200 OK — { "job_id": "...", "status": "duplicate", ... } (idempotent repeat)200 OK — { "analysis_plan": {...} } (plan=preview — nothing enqueued)400 — missing or empty file / a JSON body with neither or both of tables and rows, or an invalid one (the detail names the defect) / unknown study_type / unsafe callback_url / malformed cuts, conventions, chart_overrides, hypotheses, analysis_plan, reviewed_recommendations, market_labels / malformed or stale profile401 — bad auth403 — { "error": "plan_limit", "detail": "..." }: the org's plan does not permit this deck. API access requires the Agency plan (a key on a non-Agency plan is refused here), and an expired/over-quota plan is blocked. plan=preview is not quota-gated.413 — body exceeds MAX_CONTENT_LENGTH (50 MB)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.
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"}
| key | required | notes |
|---|---|---|
meta | ✓ | an 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 |
question | ✓ | the 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_index | the 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_composition | true 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).
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:
| field | what the browser does instead |
|---|---|
tone | not asked; the register comes from the mined headline_style / summary_register |
primary_color, secondary_color, font, sig_style, sig_confidence, deck_profile, agency | the 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 JSON | the fan-out picker and the chart picker write both keys; hand-written JSON is API-only |
banner_template, save_banner_template | not 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.
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"}
]
| field | notes |
|---|---|
label | the directional statement (shown verbatim) |
metric | nps · satisfaction · importance · return_intent (the proportion compared: promoters for NPS, top-2-box otherwise) |
cut | the column to split on (the comparison dimension) |
group_a, group_b | the two values of cut being compared |
expect | a_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):**
confirmed — a statistically significant difference (95%) IN the expected direction.refuted — a statistically significant difference in the OPPOSITE direction.inconclusive — directionally suggestive but NOT significant, or a thin base (n < 30). Never rounded up to confirmed.untestable — the metric was never measured for one/both groups (e.g. an NPS hypothesis on a survey with no recommend question). Carries no figures — nothing is fabricated.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.
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.
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 committing — plan=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": {...} } }
summary — study label/type, decision question, KPI list, section titles,disposition counts and human-readable lines ("N screening questions → one
sample-profile slide", "M questions → appendix"), warnings.
questions — one reviewable row per planned question (id, text, qtype,disposition, base, response rate).
profile — the editable JSON. Correct it (re-disposition a question,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 profile — profile=<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*:
400column, a stale plan) → 400 naming the ghost references
_inventory_sig from a different upload/sheet/filter → 400(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.
GET /api/v1/decks/<job_id>
Authorization: Bearer dk_...
200 while running — { "job_id", "status": "queued" | "processing" }200 when done — { "status": "done", "download_url", "download_name", "slide_count", "kind": "pptx"|"zip"|"xlsx" } plus, when present:warning — a |-joined string of the build's non-fatal notes (omitted questions under the response floor, sub-floor markets skipped, a font substitution, a stored house style mined by an older version, a QA-gate block on one ZIP member, a failed narrative on a data-only deck). Absent when there is nothing to say.analyses — the automatic analyses that genuinely fired for THIS deck, as short labels (wave-trend analysis, pricing analysis, trade-off analysis, key-driver analysis, segment pen-portraits). Absent when none did.style_trace, style_summary — how the deck was styled (§126): one entry per house convention a layer set, in the researcher's words — *Followed* (mined off the reference deck, with the count over its population), *Confirmed* (a mined preference the org confirmed), *Chosen* (set by the org or this request), *Default* — and a one-line summary. It is the record the build itself rendered with, read through the same function the browser poll uses, so it cannot disagree with the deck. Absent (not empty) on a zero-config build. Additive: every Loop-10 key above is unchanged.200 when failed — { "status": "failed", "error": "<reason>" }404 — unknown job id, or a job belonging to another client (IDOR-safe)
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.
202 — { "job_id", "status": "queued", "status_url", "rerun_of" } (200 + duplicate on an idempotent repeat)400 — no / empty / unsupported file, unknown study_type override403 — a project-scoped key re-running a job from another project; plan limit404 — another client's job409 — not_rerunnable: a deck built before re-run support (no stored brief)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}
| field | meaning |
|---|---|
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 |
source | the teaching deck; stale when it was mined by an older Deckr (still applied, re-POST the deck to refresh) |
profile | 0 for the house, else the project id |
mined / warning | on 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_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.
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).
delivered=false; the deck is still fetchable bypolling.
callback_url resolving to loopback / link-local(incl. 169.254.169.254) / private / reserved addresses is refused at
registration (400) and again at send time.
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.
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.
| var | purpose |
|---|---|
DECKR_PUBLIC_BASE_URL | absolute base URL used in webhook signed links (worker has no request context) |
DECKR_ARTIFACT_TTL | signed-URL lifetime in seconds (default 3600) |
DECKR_WEBHOOK_MAX_ATTEMPTS | webhook retry budget (default 4) |
DECKR_WEBHOOK_TIMEOUT | per-attempt timeout in seconds (default 5) |
DECKR_WEBHOOK_BACKOFF | backoff base in seconds, *2^attempt (default 0.5) |
DECKR_API_WORKER | set 0 to disable the in-process async worker (tests drive jobs synchronously) |
DECKR_PROFILE | dynamic 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_NAME | artifact 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).