Ext Ops Panel
Ouvrir le tableau de bord

Accueil/Docs/Fiche client

Contrat de la fiche client

Version du contrat 1.0.0 · fait partie du CRM des retours sur Indie, Studio et Fleet

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.

↓ Télécharger l’OpenAPI 3.1 (YAML) /customer-card-openapi.yaml · info.version 1.0.0

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 :

2. Configuration dans le panneau

Dans la configuration du projet, section « Widget de retours », partie « Fiche client » (propriétaire uniquement, forfait avec le CRM) :

  1. Adresse du point de terminaison. https://…, port 443 uniquement, une adresse publique (section 9).
  2. 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é.
  3. 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.
  4. Tester. Envoie une requête avec test: true pour une identité que vous saisissez (par défaut test et test@example.com). Le panneau affiche « Fiche reçue, N champs », ou le statut HTTP et jusqu’à 300 octets du corps de la réponse.
  5. 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}
ChampTypeSignification
schema_version"1.0"version du schéma de requête et de réponse
project_iduuidnotre identifiant du projet : un point de terminaison peut servir plusieurs projets
person.user_idchaîne de 128 caractères au plus, ou nulll’identifiant utilisateur signé par votre serveur (identify() plus la signature v1)
person.emailchaîne de 254 caractères au plus, ou nulll’adresse e-mail signée par votre serveur
person.verifiedtruetoujours true, car les fiches ne sont demandées que pour des personnes vérifiées ; conservé pour la compatibilité
localeen ru de es fr ptla langue du panneau de la personne qui lit, pour que vos libellés puissent revenir dans cette langue
testbooleantrue 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

Votre point de terminaison vérifie que :

  1. les deux en-têtes sont présents ;
  2. |now − timestamp| ≤ 300 secondes ;
  3. 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éeValeur
card_secretccs_test_vector_secret_not_for_production
ancien secretccs_previous_test_vector_secret
timestamp1760371200
METHOD / path_with_queryPOST / /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 secret035a47842ca90c1fd55619eccaa6aa87b4074f9157c810948d8fba656461c283
signature avec l’ancien secret1f1ecbc40946bd56eefa381a7c65e01c780a28c37f5113b345d500ed3b0b94cd
en-tête pendant la rotationv1=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

HTTPSignificationCe que voit le propriétaire
200 avec une fiche en JSONla fichela fiche
404votre 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éeun é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 champValeur
textune 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
countun entier, 0 ou plus

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.

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

typeCe que fait le panneau
linkouvre une adresse https: dans un nouvel onglet (rel="noopener noreferrer")
copycopie value dans le presse-papiers
promptaffiche 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é

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.

↓ Télécharger l’OpenAPI 3.1 (YAML) /customer-card-openapi.yaml