Presuasion submissions
1 endpoint
/v1/presuasion-submissionsSoumettre un formulaire d'outil pré-suasion
Endpoint public — convention legacy pré-CAP-440. Cet endpoint
s'écarte des conventions standard de l'API v1 sur plusieurs points
documentés ci-dessous. Il est appelé par la gateway pré-suasion
(demo.capturia.io) quand un visiteur complète un formulaire
d'outil pré-suasion publié sur le custom domain d'un client
Capturia.
Authentification : défense en profondeur
Pas de clé API Bearer (l'endpoint est appelé par une gateway publique, pas par un client tiers). Trois couches superposées protègent l'endpoint :
- Rate-limit IP (
RATE_LIMIT_CONFIG.LEAD_CREATION_LIMITreq parLEAD_WINDOW_SECONDS) — coupe le bruit haute fréquence. - Signature HMAC
X-Capturia-Signature: t=<unix>,v1=<hex>— pattern Stripe webhook, SHA-256 sur${timestamp}.${rawBody}, fenêtre replay 5 min, rotation 2-clés (PRESUASION_HMAC_SECRET+PRESUASION_HMAC_SECRET_PREVIOUS). Mode log-only par défaut, flip fail-closed viaPRESUASION_HMAC_REQUIRED=true. - Token Cloudflare Turnstile (champ
turnstile_tokendu body) vérifié siteverify avecidempotency_key=submission_iddu body (stable entre les retries d'une même soumission — un token est à usage unique, Cloudflare rejoue le verdict original sur la même clé), avec repli sur lerequest_idde la requête quand la soumission ne porte pas desubmission_id. Mode log-only par défaut, flip fail-closed viaPRESUASION_TURNSTILE_REQUIRED=true.
En complément, un quota par proposition_slug (100 submissions
par jour UTC, Upstash KV) borne le blast radius d'une attaque ciblée
même si les autres couches sont contournées.
Le client cible est résolu serveur-side via proposition_slug →
presuasion_instances.client_id — le client_id n'est jamais
accepté depuis le body.
Body et réponse au format custom
Le body suit un schéma propre à cet endpoint
(PresuasionSubmissionPayload). La réponse 200 retourne un objet
plat {success, contact_id, contact_created, activity_id} au lieu
de l'enveloppe v1 standard {data: ...}. Les erreurs retournent
{error: "string", issues?: [...]} au lieu d'ErrorEnvelope.
Comportement
- Resolve le client cible via
proposition_slugactif. - Fiche du contact par le moteur partagé des funnels : retrouvée par
le courriel (casse indifférente, courriels secondaires inclus), puis
par un téléphone déjà connu chez ce client — la fiche existante est
alors enrichie du courriel soumis en secondaire, jamais doublée ni
mise en erreur d'unicité. Noms complétés sans écraser, téléphone
posé s'il manquait (un numéro déjà porté par une autre fiche reste
sur la soumission), triplet de consentement SMS implicite rafraîchi
à chaque soumission (sous garde STOP). Sur le chemin tokenisé
(
identity_confirmed), la fiche du lien reçoit le même enrichissement additif. - Routage de capture réglé sur l'outil : une fiche créée par la soumission naît dans la colonne d'arrivée de l'outil ; les tags automatiques de l'outil sont ajoutés à toute fiche captée (créée ou retrouvée, chemin courriel ou lien) ; une fiche retrouvée qui n'est dans aucune colonne y entre par l'outil. Une fiche déjà placée n'est jamais déplacée.
- Insère une activité
presuasion_submissiondans la timeline du contact avec un bloc humainQ/Rformaté +metadatabrute (responses, result_data, tool_name, proposition). L'outil y est identifié par son instance :metadata.tool_idetmetadata.proposition_idportent l'identifiant de la proposition résolue, comme les captures des outils embarqués.
Rate limit
Émet les headers legacy X-RateLimit-* (compteur IP) — pas les
headers RateLimit-* RFC 9331 du reste de l'API v1.
Headers
| Nom | Type | Obligatoire | Description |
|---|---|---|---|
X-Capturia-Signature | string | optionnel | Signature HMAC SHA-256 calculée par le gateway sur
${timestamp}.${rawBody} avec PRESUASION_HMAC_SECRET. Fenêtre
de replay 5 minutes. Optionnel en mode log-only ; obligatoire
quand PRESUASION_HMAC_REQUIRED=true (réponse 401 sinon). |
Corps de requête
| Propriété | Type | Obligatoire | Description |
|---|---|---|---|
proposition_slug | string | obligatoire | Slug de l'instance pré-suasion (presuasion_instances.slug).
Sert à résoudre le client Capturia cible — le client_id n'est
jamais lu depuis le body. |
tool_id | string | obligatoire | Identifiant de l'outil dans le catalogue du gateway (le type d'outil,
partagé par toutes ses propositions). Informatif : l'activité de
timeline identifie l'outil par la proposition résolue depuis
proposition_slug. |
tool_name | string | obligatoire | Nom lisible de l'outil pré-suasion (affiché dans la timeline). |
email | string | optionnel | Email du visiteur (clé de dédup côté contact). Obligatoire, SAUF
quand identity_confirmed: true est accompagné d'un token valide —
le contact est alors résolu par le lien tokenisé (règle appliquée
server-side, non exprimable en JSON Schema : sans email ni
identité confirmée par token, le serveur répond 400). |
first_name | string | optionnel | Prénom optionnel — enrichit le contact si absent. |
last_name | string | optionnel | Nom de famille optionnel — enrichit le contact si absent. |
phone | string | optionnel | Téléphone optionnel — enrichit le contact si absent. Pas de validation de format. |
address | string | optionnel | Adresse formatée de la propriété (Google Places ou saisie texte). Écrite
dans la colonne structurée client_contacts.address de façon additive :
ne remplit la colonne que si elle est encore vide (jamais d'écrasement). |
postal_code | string | optionnel | Code postal extrait par Google Places (Canada). Enrichit client_contacts.postal_code si vide. |
address_place_id | string | optionnel | Google Place ID de l'adresse — deep-link Maps et désambiguïsation. Enrichit client_contacts.address_place_id si vide. |
responses | object | obligatoire | Données brutes du formulaire (paires clé/valeur libres). Stockées
telles quelles dans metadata.responses de l'activité créée. |
formatted_answers | array | obligatoire | Liste structurée Question/Réponse utilisée pour construire le bloc humain visible dans la timeline du contact. |
result_data | object | optionnel | Données calculées par l'outil pré-suasion (ex score, recommandation,
catégorie). Stockées telles quelles dans metadata.result_data. |
completed_at | string | optionnel | Horodatage de complétion côté gateway. Si absent, le serveur utilise
now(). |
turnstile_token | string | optionnel | Token Cloudflare Turnstile produit par le widget invisible du runner
de démos (demo.capturia.io et domaines custom unifiés). Vérifié
server-side via siteverify avec idempotency_key égal au
submission_id du body (stable entre les retries — un token est à
usage unique, Cloudflare rejoue le verdict original sur la même clé),
repli sur le request_id de la requête si submission_id est absent.
Optionnel en mode log-only ; obligatoire quand
PRESUASION_TURNSTILE_REQUIRED=true (réponse 403 sinon). |
submission_id | string | optionnel | Clé d'idempotence générée par la gateway, stable à travers les retries
d'une même soumission. Si une activité presuasion_submission existe
déjà avec ce submission_id pour le client résolu, le serveur retourne
l'activité existante — avant tout décompte de quota et toute écriture —
au lieu d'en créer une seconde (évite les doublons sur retry
transitoire, ex. timeout proxy après succès serveur). L'unicité est
garantie en base par un index unique partiel : deux soumissions
concurrentes portant le même submission_id produisent une seule
activité. |
token | string | optionnel | Token du lien pré-suasion personnalisé (presuasion_link_tokens),
transmis par la gateway quand le visiteur arrive par un lien envoyé à
un contact connu. Sert à l'attribution token-first du contact et au
cycle de vie du lien. Jamais utilisé après un identity_declined. |
session_key | string | optionnel | Clé anonyme de la visite (presuasion_tool_sessions.session_key),
générée côté runner. Permet de rattacher la soumission à sa visite :
rejeu du reniement, complétion de la session, et trace
metadata.session_id sur l'activité (cohorte du funnel analytics).
Absent = soumission sans suivi de visite (lead « non rattaché »). |
identity_confirmed | boolean | optionnel | Le visiteur a confirmé être le destinataire du lien tokenisé
(« C'est bien moi »). Avec token, le contact est résolu par le lien
et email devient optionnel. Mutuellement exclusif avec
identity_declined (400 sinon). |
identity_declined | boolean | optionnel | Le visiteur a déclaré ne PAS être le destinataire du lien
(« Ce n'est pas moi »). Le token n'attribue jamais le contact, le
reniement est rejoué server-side avant la complétion, et email
redevient obligatoire. Mutuellement exclusif avec
identity_confirmed (400 sinon). |
custom_domain_id | string | optionnel | Identifiant du custom domain (client_domains.id) résolu server-side par
le proxy gateway à partir du host de la soumission. Sert UNIQUEMENT à
désambiguïser la proposition quand un même slug actif existe sur plusieurs
buckets de domaine. Absent = bucket partagé (demo.capturia.io,
custom_domain_id IS NULL). Jamais utilisé pour résoudre le client_id
(toujours dérivé de la proposition côté serveur). |
ads_consent | string | optionnel | Choix du visiteur au bandeau de témoins de l'outil (Loi 25) au moment
de la capture. Gouverne l'événement Lead envoyé au pixel Meta du
client via la Conversions API : surface à bandeau, seul granted
autorise l'envoi ; denied est persisté sur la fiche et bloque tout
envoi ultérieur ; absent = aucun choix exprimé, rien ne part. |
source_url | string | optionnel | Adresse de la page de l'outil au moment de la capture
(event_source_url Meta). Quand utm et gclid sont absents, ses
paramètres utm_* et son identifiant de clic Google Ads servent de
repli à la provenance publicitaire. Best-effort : une valeur qui
n'est pas une URL http(s) absolue est ignorée (journalisée), jamais
rejetée — un champ de mesure ne fait pas échouer la soumission. |
fbp | string | optionnel | Cookie navigateur Meta _fbp, transmis tel quel à Meta — retenu
seulement quand ads_consent vaut granted. |
fbc | string | optionnel | Cookie de clic Meta _fbc (ou dérivé du fbclid), transmis tel quel
à Meta — retenu seulement quand ads_consent vaut granted. |
visitor_ip | string | optionnel | Adresse IP du visiteur, posée par le proxy de soumission des démos (l'IP vue par cette route est alors celle du proxy). Appariement Meta uniquement. Honorée seulement quand la requête porte la signature HMAC du proxy ; sans signature, l'IP de la requête elle-même fait foi et cette valeur est ignorée. |
visitor_user_agent | string | optionnel | Agent utilisateur du visiteur, posé par le proxy de soumission des
démos. Appariement Meta uniquement. Même règle que visitor_ip :
honoré seulement sous signature HMAC, sinon l'en-tête User-Agent de
la requête fait foi. |
utm | object | optionnel | Paramètres UTM de la première visite (page qui héberge l'outil).
Rattachent le lead à la campagne dont la provenance web correspond
(rapport par campagne, tag de campagne), au premier contact : une
fiche déjà attribuée ne change jamais. Retenus seulement pour une
soumission passée par l'outil (signée par son proxy ou Turnstile
valide), jamais pour la fiche d'un lien personnel (token). Un champ
illisible est écarté, jamais rejeté. |
gclid | string | optionnel | Identifiant de clic Google Ads de la première visite. Sans utm, il
vaut une provenance google / cpc. Mêmes règles que utm ; écarté
s'il est illisible. Quand ads_consent vaut denied, l'identifiant
n'est jamais conservé : seule la provenance google / cpc reste. |
captured_at | string | optionnel | Instant de la première visite. Date la provenance publicitaire ; ignoré s'il est illisible ou dans le futur (la date de la soumission fait alors foi). |
Responses
| Statut | Description |
|---|---|
200 | Soumission enregistrée. Contact upserté + activité
|
400 | Body JSON invalide ou payload Zod invalide. Convention
legacy — body custom |
401 | Signature HMAC |
403 | Token Cloudflare Turnstile manquant, expiré ou rejeté par
siteverify. Émis uniquement quand
|
404 |
|
409 | Deux variantes, refusées avant toute écriture en base (aucune fiche, activité ni enrichissement) :
|
429 | Deux variantes possibles :
|
500 | Erreur serveur (lookup proposition, upsert contact ou insert
activité). Convention legacy — body custom
|
Exemple de requête
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"
}'