Ext Ops Panel
Abrir el panel

Inicio/Docs/Ficha de cliente

Contrato de la ficha de cliente

Versión del contrato 1.0.0 · parte del CRM de comentarios en Indie, Studio y Fleet

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.

↓ Descargar OpenAPI 3.1 (YAML) /customer-card-openapi.yaml · info.version 1.0.0

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:

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

  1. Dirección del endpoint. https://…, solo el puerto 443, una dirección pública (sección 9).
  2. 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.
  3. 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.
  4. Probar. Envía una solicitud con test: true para una identidad que introduces (por defecto test y test@example.com). El panel muestra «Ficha recibida, N campos», o el estado HTTP y hasta 300 bytes del cuerpo de la respuesta.
  5. 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}
CampoTipoSignificado
schema_version"1.0"versión del esquema de solicitud y respuesta
project_iduuidnuestro id del proyecto: un endpoint puede atender varios proyectos
person.user_idcadena de hasta 128 caracteres, o nullel id de usuario que firmó tu servidor (identify() más la firma v1)
person.emailcadena de hasta 254 caracteres, o nullel correo electrónico que firmó tu servidor
person.verifiedtruesiempre true, porque las fichas se piden solo para personas verificadas; se mantiene por compatibilidad
localeen ru de es fr ptel idioma del panel de quien lee, para que tus etiquetas puedan volver en ese idioma
testbooleantrue 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

Tu endpoint comprueba que:

  1. las dos cabeceras están presentes;
  2. |now − timestamp| ≤ 300 segundos;
  3. 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.

EntradaValor
card_secretccs_test_vector_secret_not_for_production
secreto anteriorccs_previous_test_vector_secret
timestamp1760371200
METHOD / path_with_queryPOST / /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 secreto035a47842ca90c1fd55619eccaa6aa87b4074f9157c810948d8fba656461c283
firma con el secreto anterior1f1ecbc40946bd56eefa381a7c65e01c780a28c37f5113b345d500ed3b0b94cd
cabecera durante el cambiov1=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

HTTPSignificadoQué ve el propietario
200 con una ficha en JSONla fichala ficha
404tu 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 superadoun 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 campoValor
textuna 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
countun número entero, 0 o mayor

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.

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

typeQué hace el panel
linkabre una dirección https: en una pestaña nueva (rel="noopener noreferrer")
copycopia value al portapapeles
promptmuestra 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

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.

↓ Descargar OpenAPI 3.1 (YAML) /customer-card-openapi.yaml