Presuasion submissions
1 endpoint
/v1/presuasion-submissionsSubmit 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:
- IP rate limit (
RATE_LIMIT_CONFIG.LEAD_CREATION_LIMITrequests perLEAD_WINDOW_SECONDS) — cuts high-frequency noise. - 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 withPRESUASION_HMAC_REQUIRED=true. - Cloudflare Turnstile token (
turnstile_tokenbody field) verified through siteverify withidempotency_key= the body'ssubmission_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'srequest_idwhen the submission carries nosubmission_id. Log-only mode by default, switched to fail-closed withPRESUASION_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_submissionactivity into the contact's timeline with a formatted human-readableQ/Rblock + rawmetadata(responses, result_data, tool_name, proposition). The tool is identified there by its instance:metadata.tool_idandmetadata.proposition_idcarry 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
| Name | Type | Required | Description |
|---|---|---|---|
X-Capturia-Signature | string | optional | HMAC 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
| Property | Type | Required | Description |
|---|---|---|---|
proposition_slug | string | required | Slug of the pre-suasion instance (presuasion_instances.slug).
Used to resolve the target Capturia account — client_id is never read
from the body. |
tool_id | string | required | Identifier 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_name | string | required | Human-readable name of the pre-suasion tool (shown in the timeline). |
email | string | optional | Visitor'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_name | string | optional | Optional first name — enriches the contact if missing. |
last_name | string | optional | Optional last name — enriches the contact if missing. |
phone | string | optional | Optional phone number — enriches the contact if missing. No format validation. |
address | string | optional | Formatted 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_code | string | optional | Postal code extracted by Google Places (Canada). Enriches client_contacts.postal_code if empty. |
address_place_id | string | optional | Google Place ID of the address — Maps deep link and disambiguation. Enriches client_contacts.address_place_id if empty. |
responses | object | required | Raw form data (free-form key/value pairs). Stored as is in
metadata.responses of the created activity. |
formatted_answers | array | required | Structured Question/Answer list used to build the human-readable block shown in the contact's timeline. |
result_data | object | optional | Data computed by the pre-suasion tool (e.g. score, recommendation,
category). Stored as is in metadata.result_data. |
completed_at | string | optional | Completion timestamp on the gateway side. If missing, the server uses
now(). |
turnstile_token | string | optional | Cloudflare 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_id | string | optional | Idempotency 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. |
token | string | optional | Token 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_key | string | optional | Anonymous 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_confirmed | boolean | optional | The 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_declined | boolean | optional | The 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_id | string | optional | Custom 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_consent | string | optional | The 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_url | string | optional | URL 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. |
fbp | string | optional | Meta _fbp browser cookie, passed to Meta as is — kept only when
ads_consent is granted. |
fbc | string | optional | Meta _fbc click cookie (or derived from fbclid), passed to Meta as
is — kept only when ads_consent is granted. |
visitor_ip | string | optional | Visitor'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_agent | string | optional | Visitor'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. |
utm | object | optional | UTM 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. |
gclid | string | optional | Google 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_at | string | optional | Time of the first visit. Dates the ad attribution; ignored if it is unreadable or in the future (the submission date then prevails). |
Responses
| Status | Description |
|---|---|
200 | Submission recorded. Contact upserted + |
400 | Invalid JSON body or invalid Zod payload. Legacy
convention — custom |
401 |
|
403 | Cloudflare Turnstile token missing, expired, or rejected by
siteverify. Returned only when
|
404 | Unknown or disabled |
409 | Two variants, refused before any database write (no contact, activity, or enrichment):
|
429 | Two possible variants:
|
500 | Server error (proposal lookup, contact upsert, or activity
insert). Legacy convention — custom body
|
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"
}'