Cuentas administradas

MODO PRUEBA

Un cliente de Winal es un negocio suelto, o un negocio que tiene sus propios clientes — cada uno con su propio RFC, facturando de forma independiente. Si el tuyo es del segundo tipo, das de alta una cuenta administrada por cada cliente con POST /v1/accounts y después operas por ella presentando dos credenciales: la tuya, que dice quién actúa, y la de la cuenta, que dice sobre quién. Ningún endpoint nuevo de facturación, pago o lo que sea existe para esto — todo lo que ya usas sigue igual; lo único que cambia es la puerta de entrada.

Esto no es una función para un caso particular
Cualquier cuenta de Winal puede administrar cuentas: no hay una lista de clientes autorizados ni un producto separado. Si tu negocio tiene sus propios clientes que facturan por su cuenta, esta página es para ti — sea cual sea tu giro.
¿Prefieres el recorrido por pantalla?
Esta página describe el modelo y la API (cabeceras, curl, códigos de error). Si lo que quieres es qué pulsar en tu tablero, paso a paso —incluidos los dos requisitos previos, dónde se solicita la capacidad, dónde se cargan los datos fiscales de CADA cliente y cómo salir de un proveedor de timbrado mal configurado— ver Da de alta a tus clientes.

El modelo, en corto

Antes de tu primera cuenta administrada

Dos cosas se resuelven una sola vez, no cuenta por cuenta, y las dos desde tu propia consola en winal.com.mx/app (contraseña y segundo factor):

1

Solicita la capacidad

Cuentas → Solicitar. Vas a emitir CFDI a nombre de terceros bajo el contrato de PAC de Winal, así que lo aprueba la plataforma — una vez por cliente. El resto de tu cuenta funciona igual mientras se resuelve.

→
2

Declara tus orígenes

Seguridad → Orígenes permitidos. Obligatorio en cuanto administras cuentas: tu credencial va a abrir las de tus clientes y a firmar sus comprobantes fiscales, así que no puede valer desde cualquier punto de internet.

→
3

Ya puedes dar de alta

POST /v1/accounts con tu llave de siempre, dentro de una ventana de alta (ver abajo) o desde la propia consola para un alta suelta.

Si te saltas cualquiera de los dos, el alta te lo dice con el código exacto y qué hacer: account.admin_not_enabled o account.origin_allowlist_required.

La ventana de alta

Crear cuentas es exactamente lo que usaría alguien que consiguió tu llave para echar raíces: cada cuenta nueva es una credencial nueva que sobrevive a la rotación de la robada. Por eso tu llave de API sola no puede crear cuentas — necesita, además, una ventana de alta abierta desde tu consola (Cuentas → Ventana de alta, con contraseña y segundo factor): acotada en tiempo (5 minutos a 24 horas) y en número de cuentas (1 a 500). Mientras esté abierta, POST /v1/accounts funciona con tu llave de siempre; cerrada — se cierra sola al vencer, o tú la cierras cuando termines, sin pedir nada — deja de funcionar.

Es lo que te deja migrar cincuenta comercios de un tirón sin que exista, ni un minuto, una credencial permanente capaz de fabricar cuentas. Para un alta suelta —un cliente nuevo, uno a la vez— no hace falta ventana en absoluto: la creas directamente desde la consola.

Scope propio: accounts:write
Crear cuentas es administración, no operación: la llave con la que facturas todo el día no debería poder fabricar cuentas ni credenciales aunque se filtre. Emite para este propósito una llave con accounts:write desde tu consola (winal.com.mx/app → Llaves de API → Crear llave, y marca «Dar de alta cuentas de tus clientes»). La casilla aparece en cuanto Winal habilita tu cuenta para administrar cuentas de tus clientes; mientras no lo esté, la verás apagada con el motivo y el camino para pedirlo. Sin ese scope, POST /v1/accounts responde 403 insufficient_scope.

Da de alta una cuenta

reference es el identificador que esa cuenta ya tiene en tu sistema — es lo que hace idempotente el alta: repetirla con la misma referencia (con otra Idempotency-Key, incluso desde otro proceso, incluso mañana) devuelve la cuenta que ya existe con created: false, en vez de crear una segunda. scopes es opcional; si lo omites, la cuenta nace con los scopes de operación habituales (payments:read, payments:write, refunds:write, customers:write, webhooks:manage, disputes:write) — nunca con accounts:write, payouts:write ni el comodín *, que se rechazan con account.scope_not_allowed: una cuenta administrada no administra cuentas ni dispersa dinero. webhooks:manage entra a propósito, y no es un detalle — los permisos de una petición delegada son la intersección de los de las dos credenciales, así que sin él la cuenta no podría registrar sus webhooks ni con tu permiso, y tú no te enterarías de nada de lo que pasa en ella.

bash
curl -s https://api.winal.com.mx/v1/accounts \
  -H "Authorization: Bearer $SK" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "reference": "farmacia-001",
    "name": "Farmacia Ejemplo"
  }'
201 · respuesta real
{
  "account": {
    "id": "54d6557b-a52e-4e8a-9d49-880df22edeac",
    "object": "account",
    "reference": "farmacia-001",
    "name": "Farmacia Ejemplo",
    "livemode_enabled": false,
    "created_at": "2026-08-13T05:08:06.489321+00:00"
  },
  "created": true,
  "api_key": {
    "id": "ace71fa8-d642-4dc9-b68e-648bb4183763",
    "object": "api_key",
    "value": "sk_test_To6kITfivBdX0knMOz0e4qtinBMsg8qesZ2sfIfqcwF",
    "prefix": "sk_test_",
    "last4": "qcwF"
  }
}
Guarda api_key.value ya mismo
Es la única vez que lo vas a ver: Winal solo guarda el hash, igual que con cualquier otra llave. Si lo pierdes, no hay que rehacer la cuenta — emite una credencial nueva desde tu consola (Cuentas → esa cuenta → Credenciales); la cuenta nunca queda inservible. Si integras con el SDK oficial de C#, lee este campo del cuerpo crudo de la respuesta, no de la propiedad tipada — ver el aviso en Genera tu cliente.

Repetir la misma petición con la misma reference (otra Idempotency-Key) te devuelve 200 con created: false y api_key: null — no hay nada que volver a mostrar. Si necesitas otra credencial para esa cuenta, es un acto distinto (ver más abajo), no un alta repetida.

Al emitirla desde tu consola eliges sus permisos, igual que en el alta: la pantalla te ofrece el catálogo filtrado por lo que puede llevar la credencial de una cuenta administrada, con los de operación ya marcados. Es lo que hace que una cuenta creada antes de que existiera un permiso —o cuya credencial revocaste— pueda obtenerlo: antes la reemisión los fijaba a los de operación, y como recharges:write no viene de fábrica, esas cuentas no podían vender tiempo aire por ningún camino. Los permisos que el catálogo reserva al dueño (hoy recharges:write) solo los concede el dueño, también sobre la cuenta de un cliente.

Lista tus cuentas

bash
curl -s https://api.winal.com.mx/v1/accounts \
  -H "Authorization: Bearer $SK"
200 · respuesta real
{
  "object": "list",
  "data": [
    {
      "id": "54d6557b-a52e-4e8a-9d49-880df22edeac",
      "object": "account",
      "reference": "farmacia-001",
      "name": "Farmacia Ejemplo",
      "livemode_enabled": false,
      "created_at": "2026-08-13T05:08:06.489321+00:00"
    }
  ]
}

?limit= acota el resultado (por omisión 100, tope 500); no hay cursor — para 500 o menos cuentas administradas te alcanza con este único GET. Las más recientes van primero. livemode_enabled es lo único que decide la plataforma sobre cada una: cuándo pasa a producción.

Opera por una cuenta: las dos credenciales

A partir de aquí no hay ningún endpoint nuevo. Cualquier ruta de /v1 que ya usas —facturar, cobrar, crear un cliente, guardar un método de pago— acepta actuar sobre una cuenta administrada con la misma cabecera adicional:

CabeceraQué esQué responde
Authorization: Bearer sk_…tu credencial — quién actúasiempre la tuya, la del negocio que administra
Winal-Account-Key: sk_…la credencial de la cuenta — sobre quiénla que te devolvió el alta de esa cuenta

Ningún identificador de cuenta viaja en ninguna parte. Es deliberado: un identificador se puede escribir mal, y equivocarlo aquí significa timbrar un CFDI con el RFC de OTRO cliente tuyo — y un CFDI no se borra, se cancela y deja rastro fiscal. Una credencial equivocada, en cambio, sencillamente no autentica.

El ejemplo de abajo reutiliza POST /v1/invoices/pue —la venta de mostrador cobrada en efectivo, facturada sin pasar por un cobro de Winal— exactamente como en Facturación CFDI, con una única línea de más: la cabecera Winal-Account-Key.

bash
curl -s https://api.winal.com.mx/v1/invoices/pue \
  -H "Authorization: Bearer $SK" \
  -H "Winal-Account-Key: sk_test_To6kITfivBdX0knMOz0e4qtinBMsg8qesZ2sfIfqcwF" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "total_minor": 23200,
    "currency": "MXN",
    "forma_pago": "01",
    "receptor": {
      "rfc": "EKU9003173C9",
      "nombre": "ESCUELA KEMPER URGATE",
      "uso_cfdi": "G03",
      "regimen_fiscal": "601",
      "cp": "45050"
    },
    "descripcion": "Venta de mostrador"
  }'

La respuesta —éxito o error— trae la cabecera Winal-Account con el id de la cuenta sobre la que de verdad se actuó, para que la confirmes en tu propio log sin tener que volver a preguntarle a Winal:

http · cabecera de respuesta real
Winal-Account: 54d6557b-a52e-4e8a-9d49-880df22edeac
Los scopes son la intersección de las dos credenciales
Si tu llave trae invoices:write pero la de la cuenta no (o al revés), la operación queda fuera de alcance: ni una llave acotada tuya gana permisos por prestarse la de la cuenta, ni al revés. Emite ambas credenciales con los scopes que de verdad necesitas.

Configura la cuenta antes de facturar por ella

Como cualquier cuenta de Winal, una administrada necesita su perfil fiscal y su CSD antes de poder timbrar — es su RFC el que va a salir en el comprobante, así que es su propio sello digital el que lo firma ante el SAT, nunca el tuyo. Lo configuras tú, desde tu consola (Cuentas → esa cuenta → Facturación), llamando exactamente al mismo servicio que usa cualquier comercio para su propio perfil (Configura tu facturación) — solo que operado bajo el ámbito de la cuenta hija. Sin perfil fiscal, cualquier ruta de /v1/invoices sobre esa cuenta responde invoice.fiscal_profile_missing, el mismo error que vería un comercio suelto sin configurar.

Una cuenta que ya timbró no cambia de RFC
Igual que en tu propia cuenta, si intentas reemplazar el RFC de una cuenta administrada que ya emitió comprobantes, la respuesta es 409 account.fiscal_identity_locked. Su historial fiscal es el de ese contribuyente. Si tu cliente cambió de RFC, es otro contribuyente: dale de alta otra cuenta.

Firma de peticiones (opcional, recomendado si administras cuentas)

Todo lo de Firma de peticiones aplica igual aquí, con un matiz que importa MÁS en este flujo que en cualquier otro: si mandas Winal-Account-Key, su hash entra en lo que firmas. Sin eso, una petición firmada para facturarle a la farmacia A se podría re-apuntar a la farmacia B sin que la firma dejara de validar. La firma se registra y se aplica sobre tu credencial —la que actúa—, nunca sobre la de la cuenta administrada: la cuenta hija no tiene consola desde la que registrar una llave propia.

Abajo, un script que arma la cabecera Winal-Signature completa —firma RSA con openssl, sin ninguna librería— para el MISMO ejemplo de PUE de arriba, ya con la cuenta administrada. Cópialo, ajusta las tres variables de arriba y pruébalo: es exactamente el cálculo que hace el servidor, verificado contra él.

bash
#!/bin/bash
set -euo pipefail

# --- lo que cambia con tu integración ---
BASE_URL="https://api.winal.com.mx"
SK="$SK"                                            # tu credencial (Authorization)
ACCOUNT_KEY="sk_test_To6kITfivBdX0knMOz0e4qtinBMsg8qesZ2sfIfqcwF"   # la de la cuenta (Winal-Account-Key)
KEY_ID="wsk_..."                                    # key_id que te dio tu consola al registrar la llave
PRIVATE_KEY_FILE="llave_privada.pem"                # la PRIVADA — nunca sale de tu servidor
# -----------------------------------------

METHOD="POST"
PATH_ONLY="/v1/invoices/pue"
QUERY=""                                            # sin '?': cadena vacía si no hay query string
BODY='{
    "total_minor": 23200,
    "currency": "MXN",
    "forma_pago": "01",
    "receptor": {
      "rfc": "EKU9003173C9",
      "nombre": "ESCUELA KEMPER URGATE",
      "uso_cfdi": "G03",
      "regimen_fiscal": "601",
      "cp": "45050"
    },
    "descripcion": "Venta de mostrador"
  }'

sha256_hex() { openssl dgst -sha256 | awk '{print $NF}'; }   # awk $NF: robusto entre OpenSSL/LibreSSL

T=$(date +%s)
NONCE=$(openssl rand -hex 16)
BODY_HASH=$(printf '%s' "$BODY" | sha256_hex)
ACCOUNT_KEY_HASH=$(printf '%s' "$ACCOUNT_KEY" | sha256_hex)

# La cadena exacta: winal-request-v1 \n método \n ruta \n query \n t \n nonce \n
# sha256(cuerpo) \n key_id \n sha256(Winal-Account-Key) — SIN salto final si delegas.
PAYLOAD=$(printf 'winal-request-v1\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s' \
  "$METHOD" "$PATH_ONLY" "$QUERY" "$T" "$NONCE" "$BODY_HASH" "$KEY_ID" "$ACCOUNT_KEY_HASH")

SIGNATURE=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -sign "$PRIVATE_KEY_FILE" | base64 | tr -d '\n')

curl -s -i -X POST "$BASE_URL$PATH_ONLY" \
  -H "Authorization: Bearer $SK" \
  -H "Winal-Account-Key: $ACCOUNT_KEY" \
  -H "Winal-Signature: t=$T,n=$NONCE,k=$KEY_ID,v1=$SIGNATURE" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d "$BODY"
Sin delegar, quita la última línea de la cadena
Si llamas como tú mismo (sin Winal-Account-Key), la cadena firmada termina en el key_id con un salto de línea final y nada después — omite ACCOUNT_KEY_HASH del printf de arriba en vez de mandar el hash de una cadena vacía; no es lo mismo. Detalle completo, componente por componente, en Errores → Origen acotado y firma de peticiones.

Registra tu llave pública (nunca la privada) desde tu consola, en off primero para probar sin romper nada — una firma presente siempre se verifica de verdad, aunque el modo esté apagado, así que un error en el script de arriba se ve al instante y no en producción. Cuando funcione, enciende required. Detalle completo en Autenticación y seguridad → Firma de peticiones.

Orígenes: obligatorio aquí, y qué pasa si se rechaza

Ya lo declaraste en el paso 2 de arriba — es obligatorio en cuanto administras cuentas, porque tu credencial abre las de tus clientes y firma sus comprobantes. Si tu infraestructura cambia de dirección (migraste de servidor, se cayó una región), lo arreglas tú mismo desde tu consola en un solo reemplazo atómico de la lista completa — nunca llamando a Winal un sábado. Mientras tanto, cualquier petición desde una dirección no declarada se rechaza así, con la dirección observada incluida — el dato exacto que necesitas si es tu servidor nuevo:

403 · respuesta real
{
  "error": {
    "type": "authorization_error",
    "code": "account.origin_not_allowed",
    "message": "Tu cuenta solo acepta peticiones desde las direcciones que declaró, y ésta llegó desde ::1. Si es tu servidor nuevo, agrégala en tu consola (Seguridad → Orígenes permitidos); si no la reconoces, alguien más tiene tu llave: rótala ya. Avisamos al dueño de la cuenta de este intento.",
    "doc_url": "https://winal.com.mx/docs/errores.html#err-account.origin_not_allowed",
    "request_id": "0HNNOV1997MB4:00000001"
  }
}

Al dueño de tu cuenta le llega, en paralelo, una alerta operativa con la misma dirección: el rechazo no solo bloquea, también avisa — es el primer síntoma de que tu llave se filtró, si de verdad no reconoces esa dirección.

Límites de actividad por cuenta

Cada cuenta administrada tiene un tope propio de actos por hora (comprobantes emitidos, cancelados, altas) — no el límite de solicitudes que ya conoces (ése protege a la plataforma y se mide por llave), sino uno por cuenta hija, que es donde está el daño real: si alguien entra a tu sistema, no emite una factura, emite miles con el RFC de uno de tus clientes. Los topes de fábrica están muy por encima de cualquier operación normal, y tú los ajustas —o los apagas— por cuenta desde tu consola (Cuentas → esa cuenta → Límites), sin pedirle nada a Winal. Con la sola credencial de API no se pueden subir: si te robaran la llave, quien la tenga no puede levantar el tope que la está frenando. Y dentro de la consola la exigencia es asimétrica: aflojar un tope (apagarlo, quitarle el techo de monto o dejar pasar más por hora) pide tu contraseña y tu segundo factor —vale un código de respaldo—, mientras que apretarlo no pide nada: bajar un tope es la reacción de quien acaba de ver actividad que no reconoce, y tiene que estar a un clic. Detalle completo, con los números exactos, en Errores → Topes de actividad por cuenta.

Actos que exigen el segundo camino: tu consola

Además de crear cuentas (arriba), hay otros dos actos que la sola credencial de API nunca alcanza a hacer, sin importar cuánto scope traiga — porque son exactamente los que usaría alguien que se metió a tu sistema para quedarse:

Y dentro de la propia consola, desarmar un control pide lo mismo que emitir una credencial (contraseña y segundo factor), mientras que endurecerlo no pide nada. Piden confirmación: invitar a alguien a tu equipo o cambiarle el rol —crear una identidad es fabricar una credencial con la que se opera tu cuenta—, ampliar o vaciar tu lista de orígenes, apagar o relajar la exigencia de firma de peticiones, registrar una llave de firma nueva y aflojar un tope de actividad. No piden nada: estrechar la lista de orígenes, encender la firma obligatoria, revocar una llave de firma, bajar un tope y revocar credenciales. La asimetría es deliberada: son las acciones de quien acaba de sospechar una fuga, y ponerle un trámite al freno de emergencia es cómo se consigue que nadie lo use.

Los dos —igual que la ventana de alta— viven en Cuentas → esa cuenta dentro de tu consola. Intentarlos con la sola credencial de API (por ejemplo, mandando Winal-Account-Key a una ruta que no lo admite) responde 403 account.delegation_not_allowed.

Cómo se te cobra

El consumo de tus cuentas administradas se te factura a ti, desglosado por cuenta: un solo estado de cuenta agrupado, no cincuenta sueltos. Una cuenta administrada nunca tiene, a la vez, un estado de cuenta propio — sería cobrar el mismo consumo dos veces. Sin custodia (ADR-0001): el fee de Winal jamás entra al flujo de dinero de ninguno de tus clientes, ni al tuyo.

Endpoints

MétodoRutaNotas
POST/v1/accountsScope accounts:write. Exige ventana de alta abierta (o alta desde la consola). Idempotente por reference. No admite delegación.
GET/v1/accountsLista las cuentas que administras. ?limit=, por omisión 100, tope 500.

El resto de esta página no describe rutas nuevas: describe la cabecera Winal-Account-Key, que cualquier ruta existente de /v1 acepta.

Errores de esta página

HTTPcodeCausa
400account.invalid_referenceFalta reference o no cumple el formato.
400account.invalid_nameFalta name.
400account.admin_not_enabledTodavía no puedes administrar cuentas: solicítalo en tu consola.
400account.origin_allowlist_requiredFalta declarar tus orígenes antes de la primera alta.
400account.creation_window_closedNo hay ventana de alta abierta.
403insufficient_scopeTu llave no trae accounts:write.
403account.child_credential_not_standalonePusiste la credencial de una cuenta administrada en Authorization.
403account.not_a_childWinal-Account-Key es de una cuenta independiente, no de una que administres.
403account.not_your_accountEsa cuenta no es tuya (mismo mensaje si no existe).
403account.livemode_mismatchUna credencial es de prueba y la otra de producción.
403account.delegation_not_allowedEsa ruta no admite la segunda credencial.
401account.credential_invalidWinal-Account-Key inválida, revocada, o vacía.
400account.same_credentialLas dos credenciales son la misma cuenta.
409account.fiscal_identity_lockedEsa cuenta ya timbró: no cambia de RFC.
403account.origin_not_allowedPetición desde una dirección no declarada.
401account.signature_invalidLa firma no corresponde a la petición.
429account.activity_limit_reachedEsa cuenta pasó su tope de actos.

Lista completa, con el mensaje exacto de cada código y qué hacer, en Errores → Cuentas administradas y Errores → Origen acotado y firma de peticiones.