Deckr Machine-to-Machine API (v1)

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


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
fieldrequirednotes
filesurvey data: raw CSV / .xlsx / SPSS .sav / Decipher cross-tab CSV / Qualtrics export
study_typeone of the six study types (label or key); default Post-event research. Unknown → 400
event, audience, key_message, tonebrief text
primary_color, secondary_color, fontbranding (defaults to the client profile)
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). 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_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)
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.

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.)

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_...

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 gate: python3 test_api_endpoint.py (15 cases incl. adversarial/security).

See plans Back to home