Capturia mesure ses publicités et la fréquentation du site avec le pixel Meta et Google Analytics. Tu peux refuser en tout temps. En savoir plus

EN

Presuasion submissions

1 endpoint

post/v1/presuasion-submissions

Soumettre 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 :

  1. Rate-limit IP (RATE_LIMIT_CONFIG.LEAD_CREATION_LIMIT req par LEAD_WINDOW_SECONDS) — coupe le bruit haute fréquence.
  2. 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 via PRESUASION_HMAC_REQUIRED=true.
  3. Token Cloudflare Turnstile (champ turnstile_token du body) vérifié siteverify avec idempotency_key = submission_id du 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 le request_id de la requête quand la soumission ne porte pas de submission_id. Mode log-only par défaut, flip fail-closed via PRESUASION_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_slug actif.
  • 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_submission dans la timeline du contact avec un bloc humain Q/R formaté + metadata brute (responses, result_data, tool_name, proposition). L'outil y est identifié par son instance : metadata.tool_id et metadata.proposition_id portent 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

NomTypeObligatoireDescription
X-Capturia-SignaturestringoptionnelSignature 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éTypeObligatoireDescription
proposition_slugstringobligatoireSlug 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_idstringobligatoireIdentifiant 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_namestringobligatoireNom lisible de l'outil pré-suasion (affiché dans la timeline).
emailstringoptionnelEmail 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_namestringoptionnelPrénom optionnel — enrichit le contact si absent.
last_namestringoptionnelNom de famille optionnel — enrichit le contact si absent.
phonestringoptionnelTéléphone optionnel — enrichit le contact si absent. Pas de validation de format.
addressstringoptionnelAdresse 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_codestringoptionnelCode postal extrait par Google Places (Canada). Enrichit client_contacts.postal_code si vide.
address_place_idstringoptionnelGoogle Place ID de l'adresse — deep-link Maps et désambiguïsation. Enrichit client_contacts.address_place_id si vide.
responsesobjectobligatoireDonnées brutes du formulaire (paires clé/valeur libres). Stockées telles quelles dans metadata.responses de l'activité créée.
formatted_answersarrayobligatoireListe structurée Question/Réponse utilisée pour construire le bloc humain visible dans la timeline du contact.
result_dataobjectoptionnelDonnées calculées par l'outil pré-suasion (ex score, recommandation, catégorie). Stockées telles quelles dans metadata.result_data.
completed_atstringoptionnelHorodatage de complétion côté gateway. Si absent, le serveur utilise now().
turnstile_tokenstringoptionnelToken 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_idstringoptionnelClé 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é.
tokenstringoptionnelToken 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_keystringoptionnelClé 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_confirmedbooleanoptionnelLe 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_declinedbooleanoptionnelLe 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_idstringoptionnelIdentifiant 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_consentstringoptionnelChoix 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_urlstringoptionnelAdresse 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.
fbpstringoptionnelCookie navigateur Meta _fbp, transmis tel quel à Meta — retenu seulement quand ads_consent vaut granted.
fbcstringoptionnelCookie de clic Meta _fbc (ou dérivé du fbclid), transmis tel quel à Meta — retenu seulement quand ads_consent vaut granted.
visitor_ipstringoptionnelAdresse 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_agentstringoptionnelAgent 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.
utmobjectoptionnelParamè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é.
gclidstringoptionnelIdentifiant 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_atstringoptionnelInstant 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

StatutDescription
200

Soumission enregistrée. Contact upserté + activité presuasion_submission créée dans la timeline.

400

Body JSON invalide ou payload Zod invalide. Convention legacy — body custom {error, issues?} non aligné avec ErrorEnvelope du catalog v1.

401

Signature HMAC X-Capturia-Signature manquante, mal formée, expirée (fenêtre 5 min) ou invalide. Émis uniquement quand PRESUASION_HMAC_REQUIRED=true (mode fail-closed). En mode log-only, les requêtes non signées passent et un warning est émis pour observabilité.

403

Token Cloudflare Turnstile manquant, expiré ou rejeté par siteverify. Émis uniquement quand PRESUASION_TURNSTILE_REQUIRED=true (mode fail-closed).

404

proposition_slug inconnu ou désactivé (presuasion_instances.is_active = false).

409

Deux variantes, refusées avant toute écriture en base (aucune fiche, activité ni enrichissement) :

  • Démo non rattachée — le proposition_slug existe mais la proposition n'est reliée à aucun client Capturia (client_id IS NULL). Une démo sans client ne crée jamais de fiche : la soumission est refusée explicitement plutôt que simulée en succès, et le refus est journalisé côté Capturia.
  • Identité du lien invalide — identity_confirmed: true alors que le token ne résout plus aucun contact de ce client. Aucun contact n'est créé à partir d'une identité introuvable. Cette variante est évaluée après le quota par proposition_slug : quand la signature et Turnstile sont valides, elle consomme une unité du quota journalier. La démo non rattachée n'en consomme aucune.
429

Deux variantes possibles :

  • Rate limit IP dépassé — body {error: "Too many requests"}, headers X-RateLimit-* (convention legacy).
  • Quota par proposition_slug dépassé (100/jour UTC) — body {error: "Slug quota exceeded"} accompagné du header standard Retry-After (secondes jusqu'à minuit UTC) en plus des X-RateLimit-*.
500

Erreur serveur (lookup proposition, upsert contact ou insert activité). Convention legacy — body custom {error: "string"} au lieu d'ErrorEnvelope.

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"
}'