Contrat de la fiche client
Quand vous ouvrez une personne dans le CRM des retours, le panneau peut montrer qui est ce client dans votre propre système : forfait, paiements, usage du produit, un lien vers votre interface d’administration. Vous hébergez un point de terminaison HTTPS, notre serveur lui demande la fiche par une requête signée, et le panneau affiche la réponse à côté de la conversation. Nous ne conservons pas la fiche et n’exécutons jamais ses actions.
1. Fonctionnement
Le sens est le même que pour les métriques personnalisées : notre serveur appelle, le vôtre répond. Vous ouvrez une personne vérifiée dans « Personnes », dans l’onglet « Retours » d’un projet (voir le
contrat de retours, sections 1 et 9). Notre serveur envoie un POST signé à votre point de terminaison, vérifie la réponse par rapport au schéma ci-dessous et ne transmet au panneau que ce qu’il comprend. Le navigateur ne reçoit jamais le corps brut.
Quatre règles qu’aucun réglage ne change :
- Personnes vérifiées uniquement. Nous ne demandons une fiche que si votre serveur a signé l’identité de la personne (signature v1 du contrat de retours). Une personne non vérifiée ne déclenche aucune requête : n’importe qui peut saisir n’importe quelle adresse e-mail, et la fiche du compte de quelqu’un d’autre se retrouverait à côté de son message.
- Nous ne conservons jamais la fiche. Ni dans la base de données, ni dans un cache, ni dans les journaux. Chaque fois que vous ouvrez une personne, le panneau redemande.
- Nous n’exécutons jamais de paiements ni d’actions. Une action de la fiche ouvre un lien, copie une valeur ou copie un texte pour votre agent IA de support. Le panneau n’envoie rien et n’appelle rien.
- Seul le propriétaire du projet la voit, sur un forfait avec le CRM des retours. Les personnes avec qui vous partagez un projet ne voient jamais la fiche.
2. Configuration dans le panneau
Dans la configuration du projet, section « Widget de retours », partie « Fiche client » (propriétaire uniquement, forfait avec le CRM) :
- Adresse du point de terminaison.
https://…, port 443 uniquement, une adresse publique (section 9). - Secret de la fiche.
ccs_suivi de 32 octets aléatoires en base64url. Nous l’émettons et l’affichons une seule fois ; ensuite vous ne voyez que ses 4 derniers caractères. Nous le conservons chiffré. - Remplacer le secret. Le nouveau secret s’affiche une fois et l’ancien reste valable 24 heures. Pendant cette période, chaque requête porte deux signatures (section 4), ce qui vous laisse le temps de déployer le nouveau secret sur votre serveur.
- Tester. Envoie une requête avec
test: truepour une identité que vous saisissez (par défauttestettest@example.com). Le panneau affiche « Fiche reçue, N champs », ou le statut HTTP et jusqu’à 300 octets du corps de la réponse. - Activer. L’interrupteur reste désactivé tant que l’adresse et le secret ne sont pas définis.
3. La requête
POST https://support.example.com/extops/customer-card
Content-Type: application/json
Accept: application/json
User-Agent: ExtOpsPanel-CustomerCard/1.0 (+https://extops.dev/docs/customer-card)
X-ExtOps-Card-Timestamp: 1760371200
X-ExtOps-Card-Signature: v1=035a47842ca90c1fd55619eccaa6aa87b4074f9157c810948d8fba656461c283
X-ExtOps-Request-Id: 6b0d1c9e-2f4a-4c55-9d0e-1f2a3b4c5d6e
{"schema_version":"1.0","project_id":"0c6a5c1e-6f1b-4b8e-9a52-3d1f0e7c2a10","person":{"user_id":"u_42","email":"anna@example.com","verified":true},"locale":"en","test":false}
| Champ | Type | Signification |
|---|---|---|
schema_version | "1.0" | version du schéma de requête et de réponse |
project_id | uuid | notre identifiant du projet : un point de terminaison peut servir plusieurs projets |
person.user_id | chaîne de 128 caractères au plus, ou null | l’identifiant utilisateur signé par votre serveur (identify() plus la signature v1) |
person.email | chaîne de 254 caractères au plus, ou null | l’adresse e-mail signée par votre serveur |
person.verified | true | toujours true, car les fiches ne sont demandées que pour des personnes vérifiées ; conservé pour la compatibilité |
locale | en ru de es fr pt | la langue du panneau de la personne qui lit, pour que vos libellés puissent revenir dans cette langue |
test | boolean | true pour le bouton « Tester » ; vous pouvez répondre avec une fiche de démonstration |
Nous n’envoyons ni le nom de la personne, ni meta, ni le texte des messages, ni notre propre identifiant de la personne. La méthode est toujours POST : l’adresse e-mail et l’identifiant restent hors de la barre d’adresse, des journaux d’accès et des proxys, et le corps est couvert par la signature.
La requête entière a un délai de 3 secondes (2 secondes pour la connexion). Il n’y a pas de nouvelle tentative. Les redirections ne sont pas suivies : une réponse 3xx compte comme un échec.
4. Signature v1
signed = "v1|" + timestamp + "|" + METHOD + "|" + path_with_query + "|" + hex(SHA256(raw_body))
signature = hex(HMAC-SHA256(card_secret, signed))
header = "v1=" + signature
timestampest l’en-têteX-ExtOps-Card-Timestamp, temps Unix en secondes.METHODvautPOST.path_with_queryest le chemin de votre adresse avec sa query, comme dans la ligne de requête HTTP (/extops/customer-card,/hooks/card?project=a) ; un chemin vide vaut/.hexest en minuscules.card_secretest la chaîne complète du panneau (ccs_…) en octets UTF-8.- La signature couvre le corps brut, avant toute analyse JSON.
Votre point de terminaison vérifie que :
- les deux en-têtes sont présents ;
|now − timestamp| ≤ 300secondes ;- au moins une signature de l’en-tête correspond, comparée en temps constant.
Sinon, répondez 401 ; le propriétaire voit « Fiche indisponible », comme pour toute réponse autre que 200 ou 404. Une requête interceptée et rejouée dans les 5 minutes renvoie la même fiche de la même personne : le corps est signé, personne ne peut donc remplacer l’adresse e-mail. Pour une protection stricte contre le rejeu, conservez chaque X-ExtOps-Request-Id pendant 5 minutes et refusez une répétition. Cette partie est à votre choix.
Pourquoi HMAC et pas un jeton bearer : le point de terminaison de la fiche renvoie des données personnelles pour une adresse e-mail. Un jeton bearer échappé des journaux d’un proxy, d’un CDN ou d’un APM permettrait à n’importe qui de parcourir des adresses. Un secret HMAC ne circule jamais sur le réseau, et une requête interceptée n’est valable que 5 minutes et pour une seule personne.
Rotation du secret
Pendant les 24 heures qui suivent le remplacement du secret, l’en-tête porte deux signatures séparées par une virgule : v1=<new secret>,v1=<previous secret>. Acceptez la requête si l’une d’elles correspond. Les extraits ci-dessous le font déjà.
Vecteurs de test
Utilisez ces valeurs pour vérifier votre implémentation. Les tests de notre serveur fixent les mêmes valeurs.
| Entrée | Valeur |
|---|---|
card_secret | ccs_test_vector_secret_not_for_production |
| ancien secret | ccs_previous_test_vector_secret |
timestamp | 1760371200 |
METHOD / path_with_query | POST / /extops/customer-card |
| corps (une ligne, sans saut de ligne final) | {"schema_version":"1.0","project_id":"0c6a5c1e-6f1b-4b8e-9a52-3d1f0e7c2a10","person":{"user_id":"u_42","email":"anna@example.com","verified":true},"locale":"en","test":false} |
hex(SHA256(body)) | dcd0d48f2fb5d26f835b39e51076c79db02fc2bc4b98a364ab5c506959f45146 |
| signature avec le secret | 035a47842ca90c1fd55619eccaa6aa87b4074f9157c810948d8fba656461c283 |
| signature avec l’ancien secret | 1f1ecbc40946bd56eefa381a7c65e01c780a28c37f5113b345d500ed3b0b94cd |
| en-tête pendant la rotation | v1=035a47842ca90c1fd55619eccaa6aa87b4074f9157c810948d8fba656461c283,v1=1f1ecbc40946bd56eefa381a7c65e01c780a28c37f5113b345d500ed3b0b94cd |
Vérifier la signature
Node :
// Node (Express): needs the raw body, for example express.raw({ type: 'application/json' })
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
export function verifyCard(req, secret) {
const ts = req.get('X-ExtOps-Card-Timestamp');
const header = req.get('X-ExtOps-Card-Signature') || '';
if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const bodyHash = createHash('sha256').update(req.body).digest('hex');
const signed = `v1|${ts}|${req.method}|${req.originalUrl}|${bodyHash}`;
const want = Buffer.from(createHmac('sha256', secret).update(signed).digest('hex'));
return header.split(',').some((part) => {
const got = Buffer.from(part.trim().replace(/^v1=/, ''));
return got.length === want.length && timingSafeEqual(got, want);
});
}
Go :
// Go: body is the raw request body, read before json.Unmarshal
func verifyCard(r *http.Request, body []byte, secret string) bool {
ts := r.Header.Get("X-ExtOps-Card-Timestamp")
sec, err := strconv.ParseInt(ts, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-sec)) > 300 {
return false
}
sum := sha256.Sum256(body)
mac := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(mac, "v1|%s|%s|%s|%s", ts, r.Method, r.URL.RequestURI(), hex.EncodeToString(sum[:]))
want := []byte(hex.EncodeToString(mac.Sum(nil)))
for _, part := range strings.Split(r.Header.Get("X-ExtOps-Card-Signature"), ",") {
got := []byte(strings.TrimPrefix(strings.TrimSpace(part), "v1="))
if hmac.Equal(got, want) {
return true
}
}
return false
}
Python :
# Python (Flask): request.get_data() returns the raw request body
import hashlib, hmac, time
def verify_card(request, secret: str) -> bool:
ts = request.headers.get("X-ExtOps-Card-Timestamp", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
return False
body_hash = hashlib.sha256(request.get_data()).hexdigest()
path = request.full_path.rstrip("?")
signed = f"v1|{ts}|{request.method}|{path}|{body_hash}"
want = hmac.new(secret.encode(), signed.encode(), hashlib.sha256).hexdigest()
parts = request.headers.get("X-ExtOps-Card-Signature", "").split(",")
return any(hmac.compare_digest(p.strip().removeprefix("v1="), want) for p in parts)
5. La réponse : statuts
| HTTP | Signification | Ce que voit le propriétaire |
|---|---|---|
200 avec une fiche en JSON | la fiche | la fiche |
404 | votre système ne connaît pas ce client | « Client introuvable dans votre système » |
toute autre réponse, 3xx, un délai dépassé, autre chose que du JSON, une limite non respectée | un échec | « Fiche indisponible » ; la conversation continue de fonctionner |
Nous lisons au plus 300 octets d’un corps d’erreur, et seul le bouton « Tester » les montre au propriétaire.
6. La réponse : la fiche
Card {
schema_version: "1.0" required, major 1
title?: string ≤ 80 card heading
subtitle?: string ≤ 160
status?: Status overall customer status
sections: Section[] 1..8 required
actions?: Action[] ≤ 6 actions of the whole card
}
Section { id: slug ≤ 40, title?: string ≤ 60, fields: Field[] 1..20, actions?: Action[] ≤ 6 }
Field { id: slug ≤ 40, label: string ≤ 60, type: FieldType, value: <by type>, ref?: Ref, hint?: string ≤ 160 }
FieldType:
text value: string ≤ 500
money value: { amount_minor: integer, currency: ISO 4217, three letters }
date value: "YYYY-MM-DD" | RFC 3339
status value: Status
link value: { url: https-URL ≤ 2048, text?: string ≤ 80 }
count value: integer ≥ 0
Status { label: string ≤ 40, tone: "ok" | "warn" | "bad" | "neutral" }
Ref { kind: "payment" | "invoice" | "subscription" | "customer", id: string ≤ 128, provider?: string ≤ 32 }
Action:
link { type: "link", label ≤ 40, url: https-URL ≤ 2048 }
copy { type: "copy", label ≤ 40, value: string ≤ 500 }
prompt { type: "prompt", label ≤ 40, prompt: string ≤ 4000 }
| Type de champ | Valeur |
|---|---|
text | une chaîne de 500 caractères au plus |
money | { "amount_minor": 4900, "currency": "USD" } : le montant en unités mineures (centimes) et un code ISO 4217 |
date | "YYYY-MM-DD" ou une heure RFC 3339 |
status | { "label": "Active", "tone": "ok" } |
link | { "url": "https://…", "text": "Receipt" } ; le texte est facultatif |
count | un entier, 0 ou plus |
- Un ton de statut est l’un de
ok,warn,bad,neutral. - Un type de
refest l’un depayment,invoice,subscription,customer. - Un type d’action est l’un de
link,copy,prompt(section 7). - Un slug est
[a-z0-9_-], de 1 à 40 caractères. Les longueurs de chaîne se comptent en caractères Unicode. - Une fiche entière compte au plus 40 champs et 12 actions, le corps fait au plus 64 Kio, avec
Content-Type: application/jsonen UTF-8. currencypeut arriver en majuscules ou en minuscules ; le serveur le met en majuscules.refest une indication pour les machines sur l’objet du champ : un paiement, une facture, un abonnement ou un client. Le panneau ne l’affiche pas encore ; de futurs préréglages l’utiliseront. Unrefd’un type inconnu est écarté et le champ reste.
Compatibilité et éléments écartés
La fiche est dessinée par un moteur de rendu commun : le serveur analyse donc votre réponse selon les règles ci-dessous et ne transmet au panneau que les parties connues. Le navigateur ne reçoit jamais le corps brut.
- Un type de champ, un type d’action ou un ton inconnu : cet élément est écarté, le reste est affiché.
- Un élément qui dépasse une limite (une longueur, une chaîne obligatoire vide, une valeur du mauvais type) est écarté en entier, pas tronqué. Une chaîne facultative qui dépasse sa limite (
title,subtitle, le titre d’une section,hint) ou unrefinvalide est écarté seul, et l’élément reste. Les sections, champs ou actions au-delà de la limite de nombre sont écartés en partant de la fin. Les caractères de contrôle sont retirés des chaînes, et les espaces aux deux extrémités sont supprimés. - Une section sans aucun champ valide est écartée.
- Pas de
schema_versionde version majeure 1, aucune section valide, un corps de plus de 64 Kio ou un corps qui n’est pas du JSON : toute la fiche est indisponible. - Les liens sont
https:uniquement. Un champlinkavec une autre adresse s’affiche comme du texte (son texte ou l’adresse), et une actionlinkavec une telle adresse est écartée.javascript:,data:et le reste ne deviennent jamais des liens. - Les clés d’objet inconnues sont ignorées : vous pouvez donc les ajouter à l’avance.
Exemple : un produit SaaS
{
"schema_version": "1.0",
"title": "Anna Petrova",
"subtitle": "Customer since March 2025",
"status": { "label": "Active", "tone": "ok" },
"sections": [
{
"id": "plan",
"title": "Plan",
"fields": [
{ "id": "plan", "label": "Plan", "type": "text", "value": "Team, yearly" },
{ "id": "seats", "label": "Seats", "type": "count", "value": 12 },
{ "id": "mrr", "label": "MRR", "type": "money", "value": { "amount_minor": 4900, "currency": "USD" } },
{ "id": "renews", "label": "Renews", "type": "date", "value": "2026-03-14" }
]
},
{
"id": "usage",
"title": "Product",
"fields": [
{ "id": "last_seen", "label": "Last active", "type": "date", "value": "2026-10-12T18:04:00Z" },
{ "id": "errors", "label": "Failed exports, 7 days", "type": "count", "value": 3 },
{ "id": "sync", "label": "Sync", "type": "status", "value": { "label": "Paused", "tone": "warn" } }
],
"actions": [
{ "type": "link", "label": "Open in admin", "url": "https://admin.example.com/users/u_42" }
]
}
],
"actions": [
{ "type": "copy", "label": "Copy user id", "value": "u_42" },
{
"type": "prompt",
"label": "Ask the agent to check exports",
"prompt": "Customer u_42 reports failed exports. Check the last 7 days of export jobs for this user and summarise the errors. Do not change anything."
}
]
}
Exemple : un abonnement et des paiements
Un propriétaire qui encaisse via EasyPay peut marquer l’abonnement et les paiements avec ref. Des préréglages prêts à l’emploi pour les fiches de facturation viendront plus tard ; pour l’instant, c’est une fiche ordinaire.
{
"schema_version": "1.0",
"title": "Studio, monthly",
"status": { "label": "Cancelled", "tone": "warn" },
"sections": [
{
"id": "subscription",
"title": "Subscription",
"fields": [
{ "id": "status", "label": "Status", "type": "status", "value": { "label": "Ends on 14 Nov", "tone": "warn" },
"ref": { "kind": "subscription", "id": "sub_1Q2w3E4r", "provider": "easypay" } },
{ "id": "price", "label": "Price", "type": "money", "value": { "amount_minor": 1900, "currency": "USD" } },
{ "id": "ends", "label": "Access until", "type": "date", "value": "2026-11-14" }
]
},
{
"id": "payments",
"title": "Recent payments",
"fields": [
{ "id": "p1", "label": "13 Oct, paid", "type": "money", "value": { "amount_minor": 1900, "currency": "USD" },
"ref": { "kind": "payment", "id": "pi_3Pq9Lm", "provider": "easypay" }, "hint": "Invoice 4F2A-0007" },
{ "id": "inv1", "label": "Invoice", "type": "text", "value": "4F2A-0007",
"ref": { "kind": "invoice", "id": "in_1Pq9Lm", "provider": "easypay" } },
{ "id": "r1", "label": "Receipt", "type": "link", "value": { "url": "https://pay.stripe.com/receipts/example", "text": "Receipt" } },
{ "id": "cust", "label": "Customer", "type": "text", "value": "cus_Q2w3E4r",
"ref": { "kind": "customer", "id": "cus_Q2w3E4r", "provider": "easypay" } }
]
}
],
"actions": [
{ "type": "copy", "label": "Copy subscription id", "value": "sub_1Q2w3E4r" }
]
}
Le plus simple est de construire un tel point de terminaison à partir de votre propre base de données, alimentée par le webhook EasyPay : l’identifiant de l’abonnement, son statut, les dates de résiliation et de fin, et les paiements avec leur payment_intent et leur facture. Ne placez pas une clé partenaire EasyPay avec tous les droits dans le point de terminaison de la fiche.
7. Actions
type | Ce que fait le panneau |
|---|---|
link | ouvre une adresse https: dans un nouvel onglet (rel="noopener noreferrer") |
copy | copie value dans le presse-papiers |
prompt | affiche le texte et le copie pour votre agent IA de support |
Un prompt est une donnée, pas une commande : le panneau ne fait que copier le texte, sans jamais l’exécuter ni l’envoyer nulle part. Ne collez pas le texte des messages d’un visiteur dans un prompt. Ce serait une injection de prompt dans un agent qui peut avoir des droits sur de l’argent. Écrivez dans le prompt que toute action portant sur de l’argent doit être confirmée par une personne.
8. Que mettre dans la fiche
Seul le propriétaire du projet voit la fiche, mais tout ce que vous renvoyez arrive dans son navigateur. Limitez-vous à ce dont le support a besoin : le forfait, le statut de l’abonnement, les montants et dates des paiements, les compteurs du produit, des liens vers votre interface d’administration. Ne renvoyez pas de données de carte, de mots de passe, de jetons, d’adresses postales ni de données concernant d’autres personnes. Le propriétaire a déjà l’adresse e-mail et le nom grâce au message.
9. Sécurité de notre côté
- Adresse.
httpsuniquement, port 443 uniquement. L’hôte ne peut pas êtrelocalhost, un nom en.localou.internal, ni une adresse IP littérale d’un réseau privé. Nous le vérifions à l’enregistrement des réglages. - Connexion. Chaque adresse à laquelle un socket se connecte réellement est vérifiée au moment de la connexion, ce qui empêche le DNS rebinding. Sont refusés : loopback, réseaux privés (RFC 1918 et ULA), link-local y compris
169.254.169.254, adresses non spécifiées et multicast, CGNAT100.64.0.0/10,0.0.0.0/8,192.0.0.0/24,198.18.0.0/15,240.0.0.0/4et NAT6464:ff9b::/96contenant une adresse privée. Les proxys de l’environnement ne sont pas utilisés, les redirections ne sont pas suivies. - Charge. Au plus 8 requêtes de fiche simultanées par serveur (au-delà, la fiche est immédiatement indisponible), au plus 60 requêtes par minute et par projet, et le bouton « Tester » 10 fois par heure et par projet.
- Journaux. Nous journalisons l’identifiant du projet, l’hôte du point de terminaison, le statut HTTP, la durée et la classe d’erreur. Jamais le corps de la requête ou de la réponse, les adresses e-mail, les identifiants utilisateur, le chemin avec sa query ni les signatures.
- Rendu. Texte brut uniquement, ni HTML ni markdown ; les liens sont
https:uniquement. - Interrupteur d’arrêt. Nous pouvons couper les requêtes de fiche pour tout le service, par exemple pendant un incident. Aucune requête de fiche ne quitte alors nos serveurs, la fiche et le bouton « Tester » ne sont pas disponibles dans le panneau, et vos réglages sont conservés.
10. Forfaits
La fiche client fait partie du CRM des retours sur Indie, Studio et Fleet. C’est le forfait de votre compte qui compte, et un essai ne la débloque pas. Après un passage à un forfait inférieur, vos réglages sont conservés et la fiche n’est pas demandée tant que le forfait ne revient pas.
11. Le contrat lisible par machine
La description formelle du point de terminaison que vous hébergez, de la requête, du schéma de la fiche et de ses énumérations est le fichier OpenAPI 3.1 ; le point de terminaison figure sous webhooks, car c’est nous qui vous appelons. Il porte la même version du contrat (1.0.0), la première version de ce contrat.