Platforms submit a survey file + brief over HTTP and
retrieve a finished, branded deck. 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
| field | required | notes |
|---|---|---|
file | ✓ | survey data: raw CSV / .xlsx / SPSS .sav / Decipher cross-tab CSV / Qualtrics export |
study_type | one of the six study types (label or key); default Post-event research. Unknown → 400 | |
event, audience, key_message, tone | brief text | |
primary_color, secondary_color, font | branding (defaults to the client profile) | |
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). A per-run pin WINS over the org's persisted pin for the same column; 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) | |
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 file / empty file / unknown study_type / unsafe callback_url / 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.
The org's saved configuration applies here exactly as it does in the browser.
chart_overrides, 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 the first four.)
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" }200 when failed — { "status": "failed", "error": "<reason>" }404 — unknown job id, or a job belonging to another client (IDOR-safe)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 gate: python3 test_api_endpoint.py (15 cases incl. adversarial/security).