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.
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
- Un solo nivel. Una cuenta administrada NO puede a su vez administrar cuentas. La estructura es padre → hijos, nunca padre → hijo → nieto.
- Una cuenta hija es una cuenta real, con su propio
tenant, su propio historial y su propio RFC cuando llegue a facturar — no un sub-recurso ni una etiqueta dentro de la tuya. - Las cuentas administradas no tienen consola, ni usuario, ni contraseña. Tú eres el único interlocutor: las configuras, facturas por ellas y las ves todas desde tu propia cuenta.
- Nace en modo prueba, siempre. Igual que la tuya, su paso a producción lo aprueba la plataforma — nunca tú, y nunca ella misma. Ver Tu tablero → El camino a producción.
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):
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.
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.
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.
accounts:writeaccounts: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.
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"
}'
{
"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"
}
}
api_key.value ya mismoCuentas → 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
curl -s https://api.winal.com.mx/v1/accounts \
-H "Authorization: Bearer $SK"
{
"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:
| Cabecera | Qué es | Qué responde |
|---|---|---|
Authorization: Bearer sk_… | tu credencial — quién actúa | siempre la tuya, la del negocio que administra |
Winal-Account-Key: sk_… | la credencial de la cuenta — sobre quién | la 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.
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:
Winal-Account: 54d6557b-a52e-4e8a-9d49-880df22edeac
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.
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.
#!/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"
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:
{
"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:
- Emitir o rotar la credencial de una cuenta hija — si la pierdes, la cuenta jamás queda inservible: pides una nueva desde tu consola, con contraseña y segundo factor.
- Cambiar el sello digital (CSD) de una cuenta hija — es la maniobra para facturar a nombre ajeno con reputación prestada, así que exige el mismo escalón.
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étodo | Ruta | Notas |
|---|---|---|
| POST | /v1/accounts | Scope accounts:write. Exige ventana de alta abierta (o alta desde la consola). Idempotente por reference. No admite delegación. |
| GET | /v1/accounts | Lista 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
| HTTP | code | Causa |
|---|---|---|
400 | account.invalid_reference | Falta reference o no cumple el formato. |
400 | account.invalid_name | Falta name. |
400 | account.admin_not_enabled | Todavía no puedes administrar cuentas: solicítalo en tu consola. |
400 | account.origin_allowlist_required | Falta declarar tus orígenes antes de la primera alta. |
400 | account.creation_window_closed | No hay ventana de alta abierta. |
403 | insufficient_scope | Tu llave no trae accounts:write. |
403 | account.child_credential_not_standalone | Pusiste la credencial de una cuenta administrada en Authorization. |
403 | account.not_a_child | Winal-Account-Key es de una cuenta independiente, no de una que administres. |
403 | account.not_your_account | Esa cuenta no es tuya (mismo mensaje si no existe). |
403 | account.livemode_mismatch | Una credencial es de prueba y la otra de producción. |
403 | account.delegation_not_allowed | Esa ruta no admite la segunda credencial. |
401 | account.credential_invalid | Winal-Account-Key inválida, revocada, o vacía. |
400 | account.same_credential | Las dos credenciales son la misma cuenta. |
409 | account.fiscal_identity_locked | Esa cuenta ya timbró: no cambia de RFC. |
403 | account.origin_not_allowed | Petición desde una dirección no declarada. |
401 | account.signature_invalid | La firma no corresponde a la petición. |
429 | account.activity_limit_reached | Esa 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.