Capturia measures its ads and site traffic with the Meta pixel and Google Analytics. You can decline at any time. Learn more

FR

Presuasion submissions

1 endpoint

post/v1/presuasion-submissions

Submit a pre-suasion tool form

Public endpoint — legacy pre-CAP-440 convention. This endpoint departs from the standard v1 API conventions in several ways, documented below. The pre-suasion gateway (demo.capturia.io) calls it when a visitor completes a pre-suasion tool form published on a Capturia account's custom domain.

Authentication: defense in depth

No Bearer API key (the endpoint is called by a public gateway, not by a third-party client). Three stacked layers protect the endpoint:

  1. IP rate limit (RATE_LIMIT_CONFIG.LEAD_CREATION_LIMIT requests per LEAD_WINDOW_SECONDS) — cuts high-frequency noise.
  2. HMAC signature X-Capturia-Signature: t=<unix>,v1=<hex> — Stripe webhook pattern, SHA-256 over ${timestamp}.${rawBody}, 5-minute replay window, two-key rotation (PRESUASION_HMAC_SECRET + PRESUASION_HMAC_SECRET_PREVIOUS). Log-only mode by default, switched to fail-closed with PRESUASION_HMAC_REQUIRED=true.
  3. Cloudflare Turnstile token (turnstile_token body field) verified through siteverify with idempotency_key = the body's submission_id (stable across retries of the same submission — a token is single-use, and Cloudflare replays the original verdict for the same key), falling back to the request's request_id when the submission carries no submission_id. Log-only mode by default, switched to fail-closed with PRESUASION_TURNSTILE_REQUIRED=true.

In addition, a quota per proposition_slug (100 submissions per UTC day, Upstash KV) limits the blast radius of a targeted attack even if the other layers are bypassed.

The target account is resolved server-side through proposition_slug → presuasion_instances.client_id — client_id is never accepted from the body.

Custom body and response format

The body follows a schema specific to this endpoint (PresuasionSubmissionPayload). The 200 response returns a flat object {success, contact_id, contact_created, activity_id} instead of the standard v1 {data: ...} envelope. Errors return {error: "string", issues?: [...]} instead of ErrorEnvelope.

Behavior

  • Resolves the target account through an active proposition_slug.
  • Finds the contact with the shared funnel engine: matched by email (case-insensitive, secondary emails included), then by a phone number already known for this account — the existing contact is then enriched with the submitted email as a secondary email, never duplicated and never failing on a uniqueness error. Names are filled in without overwriting, the phone number is set if it was missing (a number already held by another contact stays on the submission), and the implied SMS consent triplet is refreshed on every submission (subject to the STOP guard). On the tokenized path (identity_confirmed), the link's contact receives the same additive enrichment.
  • Capture routing set on the tool: a contact created by the submission starts in the tool's entry column; the tool's automatic tags are added to every captured contact (created or matched, through the email or the link path); a matched contact that is in no column enters one through the tool. A contact already placed is never moved.
  • Inserts a presuasion_submission activity into the contact's timeline with a formatted human-readable Q/R block + raw metadata (responses, result_data, tool_name, proposition). The tool is identified there by its instance: metadata.tool_id and metadata.proposition_id carry the identifier of the resolved proposal, like captures from embedded tools.

Rate limit

Emits the legacy X-RateLimit-* headers (IP counter) — not the RFC 9331 RateLimit-* headers used by the rest of the v1 API.

Headers

NameTypeRequiredDescription
X-Capturia-SignaturestringoptionalHMAC SHA-256 signature computed by the gateway over ${timestamp}.${rawBody} with PRESUASION_HMAC_SECRET. 5-minute replay window. Optional in log-only mode; required when PRESUASION_HMAC_REQUIRED=true (401 response otherwise).

Request body

PropertyTypeRequiredDescription
proposition_slugstringrequiredSlug of the pre-suasion instance (presuasion_instances.slug). Used to resolve the target Capturia account — client_id is never read from the body.
tool_idstringrequiredIdentifier of the tool in the gateway catalog (the tool type, shared by all of its proposals). Informational only: the timeline activity identifies the tool by the proposal resolved from proposition_slug.
tool_namestringrequiredHuman-readable name of the pre-suasion tool (shown in the timeline).
emailstringoptionalVisitor's email (deduplication key for the contact). Required, EXCEPT when identity_confirmed: true comes with a valid token — the contact is then resolved through the tokenized link (rule enforced server-side, which JSON Schema cannot express: with neither an email nor an identity confirmed by token, the server responds 400).
first_namestringoptionalOptional first name — enriches the contact if missing.
last_namestringoptionalOptional last name — enriches the contact if missing.
phonestringoptionalOptional phone number — enriches the contact if missing. No format validation.
addressstringoptionalFormatted address of the property (Google Places or free-text entry). Written additively to the structured client_contacts.address column: it only fills the column while it is still empty (never overwritten).
postal_codestringoptionalPostal code extracted by Google Places (Canada). Enriches client_contacts.postal_code if empty.
address_place_idstringoptionalGoogle Place ID of the address — Maps deep link and disambiguation. Enriches client_contacts.address_place_id if empty.
responsesobjectrequiredRaw form data (free-form key/value pairs). Stored as is in metadata.responses of the created activity.
formatted_answersarrayrequiredStructured Question/Answer list used to build the human-readable block shown in the contact's timeline.
result_dataobjectoptionalData computed by the pre-suasion tool (e.g. score, recommendation, category). Stored as is in metadata.result_data.
completed_atstringoptionalCompletion timestamp on the gateway side. If missing, the server uses now().
turnstile_tokenstringoptionalCloudflare Turnstile token produced by the invisible widget of the demo runner (demo.capturia.io and unified custom domains). Verified server-side through siteverify with an idempotency_key equal to the body's submission_id (stable across retries — a token is single-use, and Cloudflare replays the original verdict for the same key), falling back to the request's request_id when submission_id is missing. Optional in log-only mode; required when PRESUASION_TURNSTILE_REQUIRED=true (403 response otherwise).
submission_idstringoptionalIdempotency key generated by the gateway, stable across retries of the same submission. If a presuasion_submission activity already exists with this submission_id for the resolved account, the server returns the existing activity — before any quota count and any write — instead of creating a second one (prevents duplicates on a transient retry, e.g. a proxy timeout after a server-side success). Uniqueness is guaranteed in the database by a partial unique index: two concurrent submissions with the same submission_id produce a single activity.
tokenstringoptionalToken of the personalized pre-suasion link (presuasion_link_tokens), passed by the gateway when the visitor arrives through a link sent to a known contact. Used for token-first attribution of the contact and for the link's lifecycle. Never used after an identity_declined.
session_keystringoptionalAnonymous key of the visit (presuasion_tool_sessions.session_key), generated by the runner. Ties the submission to its visit: replay of the identity denial, session completion, and a metadata.session_id trace on the activity (funnel analytics cohort). Missing = submission without visit tracking ("unlinked" lead).
identity_confirmedbooleanoptionalThe visitor confirmed being the recipient of the tokenized link ("That's me"). With token, the contact is resolved through the link and email becomes optional. Mutually exclusive with identity_declined (400 otherwise).
identity_declinedbooleanoptionalThe visitor stated they are NOT the recipient of the link ("That's not me"). The token never attributes the contact, the denial is replayed server-side before completion, and email becomes required again. Mutually exclusive with identity_confirmed (400 otherwise).
custom_domain_idstringoptionalCustom domain identifier (client_domains.id) resolved server-side by the gateway proxy from the submission's host. Used ONLY to disambiguate the proposal when the same active slug exists in several domain buckets. Missing = shared bucket (demo.capturia.io, custom_domain_id IS NULL). Never used to resolve client_id (always derived from the proposal on the server side).
ads_consentstringoptionalThe visitor's choice in the tool's cookie banner (Quebec Law 25) at capture time. Governs the Lead event sent to the account's Meta pixel through the Conversions API: on a surface with a banner, only granted allows sending; denied is persisted on the contact and blocks any later sending; missing = no choice expressed, nothing is sent.
source_urlstringoptionalURL of the tool page at capture time (Meta event_source_url). When utm and gclid are missing, its utm_* parameters and its Google Ads click identifier serve as a fallback for ad attribution. Best effort: a value that is not an absolute http(s) URL is ignored (and logged), never rejected — a measurement field never makes the submission fail.
fbpstringoptionalMeta _fbp browser cookie, passed to Meta as is — kept only when ads_consent is granted.
fbcstringoptionalMeta _fbc click cookie (or derived from fbclid), passed to Meta as is — kept only when ads_consent is granted.
visitor_ipstringoptionalVisitor's IP address, set by the demo submission proxy (the IP seen by this route is then the proxy's). Used for Meta matching only. Honored only when the request carries the proxy's HMAC signature; without a signature, the request's own IP prevails and this value is ignored.
visitor_user_agentstringoptionalVisitor's user agent, set by the demo submission proxy. Used for Meta matching only. Same rule as visitor_ip: honored only under an HMAC signature; otherwise the request's User-Agent header prevails.
utmobjectoptionalUTM parameters of the first visit (the page that hosts the tool). They attach the lead to the campaign whose web source matches (per-campaign report, campaign tag), on first touch: a contact that is already attributed never changes. Kept only for a submission that went through the tool (signed by its proxy or with a valid Turnstile token), never for the contact of a personal link (token). An unreadable field is dropped, never rejected.
gclidstringoptionalGoogle Ads click identifier of the first visit. Without utm, it counts as a google / cpc source. Same rules as utm; dropped if unreadable. When ads_consent is denied, the identifier is never stored: only the google / cpc source remains.
captured_atstringoptionalTime of the first visit. Dates the ad attribution; ignored if it is unreadable or in the future (the submission date then prevails).

Responses

StatusDescription
200

Submission recorded. Contact upserted + presuasion_submission activity created in the timeline.

400

Invalid JSON body or invalid Zod payload. Legacy convention — custom {error, issues?} body that does not follow the v1 catalog's ErrorEnvelope.

401

X-Capturia-Signature HMAC signature missing, malformed, expired (5-minute window), or invalid. Returned only when PRESUASION_HMAC_REQUIRED=true (fail-closed mode). In log-only mode, unsigned requests go through and a warning is emitted for observability.

403

Cloudflare Turnstile token missing, expired, or rejected by siteverify. Returned only when PRESUASION_TURNSTILE_REQUIRED=true (fail-closed mode).

404

Unknown or disabled proposition_slug (presuasion_instances.is_active = false).

409

Two variants, refused before any database write (no contact, activity, or enrichment):

  • Unlinked demo — the proposition_slug exists but the proposal is not linked to any Capturia account (client_id IS NULL). A demo without an account never creates a contact: the submission is explicitly refused rather than faked as a success, and the refusal is logged on the Capturia side.
  • Invalid link identity — identity_confirmed: true while the token no longer resolves to any contact of this account. No contact is created from an identity that cannot be found. This variant is evaluated after the per-proposition_slug quota: when the signature and Turnstile are valid, it consumes one unit of the daily quota. The unlinked demo consumes none.
429

Two possible variants:

  • IP rate limit exceeded — {error: "Too many requests"} body, X-RateLimit-* headers (legacy convention).
  • Per-proposition_slug quota exceeded (100/UTC day) — body {error: "Slug quota exceeded"} with the standard Retry-After header (seconds until UTC midnight) in addition to the X-RateLimit-* headers.
500

Server error (proposal lookup, contact upsert, or activity insert). Legacy convention — custom body {error: "string"} instead of ErrorEnvelope.

Example request

curl -X POST 'https://api.capturia.io/v1/presuasion-submissions' \
  -H 'Authorization: Bearer cap_live_<your-key>' \
  -H 'Content-Type: application/json' \
  -d '{
  "proposition_slug": "blueprint-ventes-2026",
  "tool_id": "calculateur-roi",
  "tool_name": "Calculateur de ROI",
  "email": "[email protected]",
  "first_name": "Alex",
  "phone": "+15145551234",
  "responses": {
    "budget": "500-1000",
    "timeline": "3-mois"
  },
  "formatted_answers": [
    {
      "question": "Quel est ton défi principal en ventes ?",
      "answer": "Je perds des leads parce que je ne suis pas assez rapide à les rappeler."
    }
  ],
  "result_data": {
    "score": 87,
    "recommendation": "premium"
  },
  "completed_at": "2026-05-04T12:34:56.789Z"
}'