Contrato de la ficha de cliente
Cuando abres a una persona en el CRM de comentarios, el panel puede mostrar quién es ese cliente en tu propio sistema: plan, pagos, uso del producto, un enlace a tu panel de administración. Alojas un endpoint HTTPS, nuestro servidor le pide la ficha con una solicitud firmada y el panel muestra la respuesta junto a la conversación. No almacenamos la ficha y nunca ejecutamos sus acciones.
1. Cómo funciona
La dirección es la misma que en las métricas personalizadas: nuestro servidor llama y el tuyo responde. Abres a una persona verificada en «Personas», en la pestaña «Comentarios» de un proyecto (mira el
contrato de comentarios, secciones 1 y 9). Nuestro servidor envía un POST firmado a tu endpoint, compara la respuesta con el esquema de abajo y pasa al panel solo las partes que entiende. El navegador nunca recibe el cuerpo sin procesar.
Cuatro reglas que ningún ajuste cambia:
- Solo personas verificadas. Pedimos una ficha solo cuando tu servidor firmó la identidad de la persona (firma v1 del contrato de comentarios). Para una persona no verificada no hay solicitud: cualquiera puede escribir cualquier correo, y la ficha de la cuenta de otra persona acabaría junto a su mensaje.
- Nunca almacenamos la ficha. Ni en la base de datos, ni en una caché, ni en los registros. Cada vez que abres a una persona, el panel vuelve a preguntar.
- Nunca ejecutamos pagos ni acciones. Una acción de la ficha abre un enlace, copia un valor o copia un texto para tu agente de IA de soporte. El panel no envía nada y no llama a nada.
- Solo la ve el propietario del proyecto, en un plan con el CRM de comentarios. Las personas con las que compartes un proyecto nunca ven la ficha.
2. Configuración en el panel
En la configuración del proyecto, sección «Widget de comentarios», apartado «Ficha de cliente» (solo el propietario, un plan con el CRM):
- Dirección del endpoint.
https://…, solo el puerto 443, una dirección pública (sección 9). - Secreto de la ficha.
ccs_seguido de 32 bytes aleatorios en base64url. Lo emitimos nosotros y lo mostramos una vez; después solo ves sus últimos 4 caracteres. Lo guardamos cifrado. - Reemplazar secreto. El nuevo secreto se muestra una vez y el anterior sigue funcionando 24 horas. Durante ese plazo cada solicitud lleva dos firmas (sección 4), lo que te da tiempo para instalar el nuevo secreto en tu servidor.
- Probar. Envía una solicitud con
test: truepara una identidad que introduces (por defectotestytest@example.com). El panel muestra «Ficha recibida, N campos», o el estado HTTP y hasta 300 bytes del cuerpo de la respuesta. - Activar. El interruptor no se activa hasta que estén definidos la dirección y el secreto.
3. La solicitud
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}
| Campo | Tipo | Significado |
|---|---|---|
schema_version | "1.0" | versión del esquema de solicitud y respuesta |
project_id | uuid | nuestro id del proyecto: un endpoint puede atender varios proyectos |
person.user_id | cadena de hasta 128 caracteres, o null | el id de usuario que firmó tu servidor (identify() más la firma v1) |
person.email | cadena de hasta 254 caracteres, o null | el correo electrónico que firmó tu servidor |
person.verified | true | siempre true, porque las fichas se piden solo para personas verificadas; se mantiene por compatibilidad |
locale | en ru de es fr pt | el idioma del panel de quien lee, para que tus etiquetas puedan volver en ese idioma |
test | boolean | true para el botón «Probar»; puedes responder con una ficha de demostración |
No enviamos el nombre de la persona, meta, el texto de los mensajes ni nuestro propio id de la persona. El método es siempre POST, así el correo y el id no quedan en la barra de direcciones, en los registros de acceso ni en los proxies, y el cuerpo queda cubierto por la firma.
La solicitud completa tiene un tiempo de espera de 3 segundos (2 segundos para conectar). No hay reintentos. No se siguen las redirecciones: una respuesta 3xx cuenta como fallo.
4. Firma v1
signed = "v1|" + timestamp + "|" + METHOD + "|" + path_with_query + "|" + hex(SHA256(raw_body))
signature = hex(HMAC-SHA256(card_secret, signed))
header = "v1=" + signature
timestampes la cabeceraX-ExtOps-Card-Timestamp, tiempo Unix en segundos.METHODesPOST.path_with_queryes la ruta de tu dirección con su query, como en la línea de solicitud HTTP (/extops/customer-card,/hooks/card?project=a); una ruta vacía es/.hexva en minúsculas.card_secretes la cadena completa del panel (ccs_…) como bytes UTF-8.- La firma cubre el cuerpo sin procesar, antes de cualquier análisis JSON.
Tu endpoint comprueba que:
- las dos cabeceras están presentes;
|now − timestamp| ≤ 300segundos;- al menos una firma de la cabecera coincide, comparada en tiempo constante.
Si no, responde 401; el propietario ve «Ficha no disponible», como con cualquier respuesta distinta de 200 o 404. Una solicitud interceptada y repetida en menos de 5 minutos devuelve la misma ficha de la misma persona: el cuerpo está firmado, así que nadie puede cambiar el correo. Para una protección estricta contra repeticiones, guarda cada X-ExtOps-Request-Id durante 5 minutos y rechaza las repeticiones. Esa parte queda a tu criterio.
Por qué HMAC y no un token bearer: el endpoint de la ficha devuelve datos personales a partir de un correo. Un token bearer filtrado desde los registros de un proxy, una CDN o un APM permitiría a cualquiera recorrer direcciones. Un secreto HMAC nunca viaja por la red, y una solicitud interceptada vale 5 minutos y para una sola persona.
Cambio de secreto
Durante las 24 horas siguientes al reemplazo del secreto, la cabecera lleva dos firmas separadas por una coma: v1=<new secret>,v1=<previous secret>. Acepta la solicitud si coincide cualquiera de ellas. Los fragmentos de código de abajo ya lo hacen.
Vectores de prueba
Usa estos valores para comprobar tu implementación. Las pruebas de nuestro servidor fijan los mismos valores.
| Entrada | Valor |
|---|---|
card_secret | ccs_test_vector_secret_not_for_production |
| secreto anterior | ccs_previous_test_vector_secret |
timestamp | 1760371200 |
METHOD / path_with_query | POST / /extops/customer-card |
| cuerpo (una línea, sin salto de línea 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 |
| firma con el secreto | 035a47842ca90c1fd55619eccaa6aa87b4074f9157c810948d8fba656461c283 |
| firma con el secreto anterior | 1f1ecbc40946bd56eefa381a7c65e01c780a28c37f5113b345d500ed3b0b94cd |
| cabecera durante el cambio | v1=035a47842ca90c1fd55619eccaa6aa87b4074f9157c810948d8fba656461c283,v1=1f1ecbc40946bd56eefa381a7c65e01c780a28c37f5113b345d500ed3b0b94cd |
Comprobar la firma
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 respuesta: estados
| HTTP | Significado | Qué ve el propietario |
|---|---|---|
200 con una ficha en JSON | la ficha | la ficha |
404 | tu sistema no tiene ese cliente | «Cliente no encontrado en tu sistema» |
cualquier otra cosa, 3xx, un tiempo de espera agotado, algo que no es JSON, un límite superado | un fallo | «Ficha no disponible»; la conversación sigue funcionando |
De un cuerpo de error leemos como máximo 300 bytes, y solo el botón «Probar» se los muestra al propietario.
6. La respuesta: la ficha
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 }
| Tipo de campo | Valor |
|---|---|
text | una cadena de hasta 500 caracteres |
money | { "amount_minor": 4900, "currency": "USD" }: el importe en unidades menores (céntimos) y un código ISO 4217 |
date | "YYYY-MM-DD" o una hora RFC 3339 |
status | { "label": "Active", "tone": "ok" } |
link | { "url": "https://…", "text": "Receipt" }; el texto es opcional |
count | un número entero, 0 o mayor |
- Un tono de estado es uno de
ok,warn,bad,neutral. - Un tipo de
refes uno depayment,invoice,subscription,customer. - Un tipo de acción es uno de
link,copy,prompt(sección 7). - Un slug es
[a-z0-9_-], de 1 a 40 caracteres. Las longitudes de las cadenas cuentan caracteres Unicode. - Una ficha completa tiene como máximo 40 campos y 12 acciones, el cuerpo ocupa como máximo 64 KiB, con
Content-Type: application/jsonen UTF-8. currencypuede venir en mayúsculas o minúsculas; el servidor lo pasa a mayúsculas.refes una pista para máquinas sobre de qué trata el campo: un pago, una factura, una suscripción o un cliente. El panel todavía no lo muestra; lo usarán futuros preajustes. Unrefcon un tipo desconocido se descarta y el campo se queda.
Compatibilidad y qué se descarta
La ficha la dibuja un renderizador común, así que el servidor analiza tu respuesta según las reglas de abajo y pasa al panel solo las partes conocidas. El navegador nunca recibe el cuerpo sin procesar.
- Un tipo de campo, tipo de acción o tono desconocido: ese elemento se descarta y el resto se muestra.
- Un elemento que supera un límite (una longitud, una cadena obligatoria vacía, un valor del tipo equivocado) se descarta entero, no se recorta. Una cadena opcional que supera su límite (
title,subtitle, el título de una sección,hint) o unrefno válido se descarta solo, y el elemento se queda. Las secciones, campos o acciones que superan el límite de cantidad se descartan desde el final. Los caracteres de control se eliminan de las cadenas y los espacios de ambos extremos se recortan. - Una sección sin ningún campo válido se descarta.
- Sin
schema_versioncon versión mayor 1, sin ninguna sección válida, un cuerpo de más de 64 KiB o un cuerpo que no es JSON: toda la ficha no está disponible. - Los enlaces son solo
https:. Un campolinkcon otra dirección se muestra como texto (su texto o la dirección), y una acciónlinkcon una así se descarta.javascript:,data:y el resto nunca se convierten en enlaces. - Las claves de objeto desconocidas se ignoran, así que puedes añadirlas con antelación.
Ejemplo: un producto 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."
}
]
}
Ejemplo: una suscripción y pagos
Un propietario que cobra a través de EasyPay puede marcar la suscripción y los pagos con ref. Los preajustes listos para fichas de facturación llegarán más adelante; por ahora es una ficha normal.
{
"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" }
]
}
La forma más sencilla de crear un endpoint así es a partir de tu propia base de datos, que llena el webhook de EasyPay: el id de la suscripción, su estado, las fechas de cancelación y de fin, y los pagos con su payment_intent y su factura. No pongas una clave de socio de EasyPay con todos los permisos en el endpoint de la ficha.
7. Acciones
type | Qué hace el panel |
|---|---|
link | abre una dirección https: en una pestaña nueva (rel="noopener noreferrer") |
copy | copia value al portapapeles |
prompt | muestra el texto y lo copia para tu agente de IA de soporte |
Un prompt son datos, no una orden: el panel solo copia el texto y nunca lo ejecuta ni lo envía a ningún sitio. No pegues en un prompt el texto de los mensajes de un visitante. Eso es una inyección de prompt en un agente que puede tener permisos sobre dinero. Escribe en el prompt que cualquier acción con dinero debe confirmarla una persona.
8. Qué poner en la ficha
Solo el propietario del proyecto ve la ficha, pero todo lo que devuelves llega a su navegador. Limítate a lo que necesita el soporte: el plan, el estado de la suscripción, importes y fechas de pago, contadores del producto, enlaces a tu panel de administración. No devuelvas datos de tarjetas, contraseñas, tokens, direcciones postales ni datos de otras personas. El propietario ya tiene el correo y el nombre por el mensaje.
9. Seguridad de nuestro lado
- Dirección. Solo
https, solo el puerto 443. El host no puede serlocalhost, un nombre.localo.internal, ni un literal IP de una red privada. Lo comprobamos al guardar los ajustes. - Conexión. Cada dirección a la que se conecta realmente un socket se comprueba al conectar, lo que impide el DNS rebinding. Se rechazan loopback, las redes privadas (RFC 1918 y ULA), link-local incluido
169.254.169.254, las direcciones no especificadas y 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/4y NAT6464:ff9b::/96con una dirección privada dentro. No se usan proxies del entorno y no se siguen redirecciones. - Carga. Como máximo 8 solicitudes de ficha a la vez por servidor (por encima de eso la ficha no está disponible de inmediato), como máximo 60 solicitudes por minuto por proyecto, y el botón «Probar» 10 veces por hora por proyecto.
- Registros. Registramos el id del proyecto, el host del endpoint, el estado HTTP, la duración y la clase de error. Nunca el cuerpo de la solicitud o de la respuesta, direcciones de correo, ids de usuario, la ruta con su query ni las firmas.
- Renderizado. Solo texto plano, sin HTML ni markdown; los enlaces son solo
https:. - Interruptor de apagado. Podemos desactivar las solicitudes de ficha para todo el servicio, por ejemplo durante un incidente. Entonces ninguna solicitud de ficha sale de nuestros servidores, la ficha y el botón «Probar» no están disponibles en el panel, y tus ajustes se conservan.
10. Planes
La ficha de cliente forma parte del CRM de comentarios en Indie, Studio y Fleet. Cuenta el plan de tu cuenta, y una prueba no la activa. Después de bajar de plan tus ajustes se conservan y la ficha no se pide hasta que vuelva el plan.
11. El contrato legible por máquina
La descripción formal del endpoint que alojas, de la solicitud, del esquema de la ficha y de sus enumeraciones es el archivo OpenAPI 3.1; el endpoint está en webhooks, porque nosotros te llamamos. Lleva la misma versión del contrato (1.0.0), la primera versión de este contrato.