Guía para recibir en tus sistemas un aviso cada vez que un ticket cambia de estatus: cómo configurarlo, qué llega, cómo verificar la firma y qué hacer cuando algo falla.
Dirigido a: equipos técnicos de las empresas cliente. Los términos (asociación, asociación raíz, colaborador, ticket de asociación) se definen en Conceptos. En la web app business, a las asociaciones se les llama empresas.
Qué es un webhook
Un webhook es una petición POST que Fotofacturas envía a una URL tuya cuando un ticket cambia de estatus. Así te enteras al momento de que un ticket se facturó o se rechazó, sin consultar la API una y otra vez.
- Aplica a los tickets de cualquier canal (app móvil, app web, WhatsApp, flujo embebido, web app business o API Enterprise), siempre que haya un webhook activo que aplique (ver Qué webhook se usa para cada ticket).
- El aviso trae solo identificadores y estatus. Para el detalle (monto, PDF, XML) consulta
GetTicketByIdcon eluserTicketId.
Inicio rápido
-
Publica un endpoint HTTPS en el puerto 443, accesible desde internet.
-
Registra el webhook en la asociación raíz, desde la web app business (Webhooks) o con
SaveWebhook:POST https://api.fotofacturas.ai/B2B/WebhookV1/SaveWebhook Authorization: Bearer <token> { "associationId": 41, "eventType": 1, "url": "https://erp.empresa.com/webhooks/fotofacturas/invoiced", "isActive": true } -
Guarda el secreto (
data.secret, empieza conwhsec_). Solo se muestra al crear el webhook. -
Verifica la firma de cada aviso (ver sección 6) y responde
2xxen menos de 10 segundos. -
Prueba con Probar en la web app business o con
TestWebhook. Tu endpoint recibirá un eventoping.
Para usar las APIs de webhooks, tu cuenta necesita sus permisos de API (ver sección 10). Si no los tienes, pídelos a Fotofacturas.
Eventos
eventType | Nombre (X-FF-Event) | Cuándo se envía |
|---|---|---|
| 1 | ticket.invoiced | El ticket pasó a 11 SIGNED (facturado). |
| 2 | ticket.failed | El ticket pasó a 10 UNSIGNABLE (no facturable). El motivo está en ticketCodeId. |
| 3 | ticket.status_changed | El ticket cambió a cualquier estatus, incluida su creación (null → 1). |
- Un mismo cambio puede generar dos eventos: al facturarse se generan
ticket.status_changedyticket.invoiced, cada uno con su propioeventIdy enviado a su propia URL. - Solo se avisa cuando el estatus cambia. No todos los tickets pasan por todos los estatus intermedios.
- Si solo te interesa el resultado final, suscríbete a
ticket.invoicedyticket.failed.
Los estatus están en Catálogos y Flujo del ticket.
Configuración
Un webhook por evento y por asociación
Un webhook es la combinación de una asociación, un evento, una URL y un secreto. Cada asociación puede tener uno por evento (tres en total).
SaveWebhookcrea el webhook o, si ya existe, actualiza suurleisActive. El secreto no cambia al actualizar.- Cada webhook tiene su propio secreto.
- Los webhooks no se eliminan; se desactivan con
isActive: false(en la web app business, apagando Enviar notificaciones).
Qué webhook se usa para cada ticket
Para cada evento, Fotofacturas busca:
- El webhook activo de ese evento en la asociación del ticket.
- Si no hay, el webhook activo de ese evento en la asociación raíz.
- Si tampoco hay, ese evento no se genera.
- No se usan los webhooks de asociaciones intermedias. Con Raíz → Región Norte → Sucursal Monterrey y un webhook solo en Región Norte, los tickets de Sucursal Monterrey usan el de la raíz.
- Si desactivas el webhook de una asociación que no es la raíz, sus tickets pasan a usar el de la raíz (si está activo).
- Lo más simple: configura los webhooks solo en la asociación raíz.
data.associationIdte dice de qué asociación es cada ticket.
Requisitos de la URL
| Requisito | Mensaje si no se cumple |
|---|---|
Esquema https:// | La URL debe usar HTTPS |
| Puerto 443 | La URL debe usar el puerto 443 |
| Sin usuario ni contraseña | La URL no puede incluir usuario ni contraseña |
| Dominio que resuelva solo a IPs públicas | No se pudo resolver el dominio de la URL / La URL apunta a una dirección no permitida |
| Máximo 1000 caracteres | La URL no puede tener más de 1000 caracteres |
Las IPs se validan también en cada envío. Las redirecciones no se siguen.
Cuándo empiezan a llegar
Los eventos se generan en el momento del cambio de estatus y solo si en ese momento hay un webhook activo que aplique. Crear o activar un webhook no genera avisos de cambios anteriores; para ponerte al día usa las APIs de reportes.
La notificación
Petición
POST https://erp.empresa.com/webhooks/fotofacturas/invoiced
Content-Type: application/json; charset=utf-8
User-Agent: Fotofacturas-Webhooks/1.0
X-FF-Event: ticket.invoiced
X-FF-Event-Id: 98123
X-FF-Timestamp: 1791900922
X-FF-Signature: v1=5d41402abc4b2a76b9719d911017c592ae1f3c6a8e1d7b2c9f0e4a6b3d8c1e2f
| Header | Contenido |
|---|---|
X-FF-Event | ticket.invoiced, ticket.failed, ticket.status_changed o ping. |
X-FF-Event-Id | El eventId del body, o ping en las pruebas. |
X-FF-Timestamp | Momento del envío en segundos Unix (UTC). Cambia en cada intento. |
X-FF-Signature | Firma HMAC-SHA256 con el prefijo v1=. |
Body
{
"eventId": 98123,
"eventType": "ticket.invoiced",
"occurredAt": "2026-10-13T10:15:22.123",
"data": {
"userTicketId": 88254,
"userId": 3,
"associationId": 42,
"ticketStatusId": 11,
"ticketStatus": "SIGNED",
"previousTicketStatusId": 5,
"ticketCodeId": 2
}
}
| Campo | Tipo | Descripción |
|---|---|---|
eventId | long | Id único del evento. Úsalo para descartar duplicados. |
eventType | string | Nombre del evento (igual a X-FF-Event). |
occurredAt | fecha | Cuándo cambió el estatus, en hora de México. |
data.userTicketId | int | Id del ticket. |
data.userId | int | null | Colaborador dueño del ticket. null en los tickets de asociación. |
data.associationId | int | Asociación del ticket (no necesariamente la del webhook). |
data.ticketStatusId / data.ticketStatus | int / string | Estatus nuevo. |
data.previousTicketStatusId | int | null | Estatus anterior. null cuando el ticket se acaba de crear. |
data.ticketCodeId | int | Código del ticket en ese momento (catálogo GetTicketCodes). |
Evento de prueba (ping)
{
"eventId": null,
"eventType": "ping",
"occurredAt": "2026-10-13T10:00:00",
"data": {
"webhookSubscriptionId": 7,
"associationId": 41,
"subscribedEventType": "ticket.invoiced"
}
}
Va firmado igual que los demás. Se envía aunque el webhook esté desactivado, no se reintenta y no queda en el historial. Responde 2xx aunque no lo proceses.
Verificar la firma
Verifica la firma siempre antes de procesar el aviso:
- Lee el body exacto, en bytes, antes de convertirlo a JSON.
- Arma el texto firmado:
X-FF-Timestamp+.+ body. Ejemplo:1791900922.{"eventId":98123,...}. - Calcula
HMAC-SHA256de ese texto con el secreto completo (incluidowhsec_) como llave, en hexadecimal minúsculas. - Compáralo en tiempo constante con
X-FF-Signaturesin el prefijov1=. - Rechaza el aviso si
X-FF-Timestampdifiere más de 5 minutos de tu reloj.
Si la firma no es válida, responde 401 y no proceses el aviso.
Node.js (Express)
const express = require('express');
const crypto = require('crypto');
const app = express();
const SECRET = process.env.FF_WEBHOOK_SECRET;
app.post('/webhooks/fotofacturas/invoiced', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.get('X-FF-Timestamp') || '';
const signature = (req.get('X-FF-Signature') || '').replace(/^v1=/, '');
const expected = crypto.createHmac('sha256', SECRET).update(`${timestamp}.`).update(req.body).digest('hex');
const valid = signature.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;
if (!valid || !fresh) return res.sendStatus(401);
res.sendStatus(200);
enqueue(JSON.parse(req.body.toString('utf8')));
});
C# (ASP.NET Core)
string secret = builder.Configuration["FF_WEBHOOK_SECRET"]!;
app.MapPost("/webhooks/fotofacturas/invoiced", async (HttpRequest request) =>
{
using StreamReader reader = new(request.Body, Encoding.UTF8);
string rawBody = await reader.ReadToEndAsync();
string timestamp = request.Headers["X-FF-Timestamp"].ToString();
string signature = request.Headers["X-FF-Signature"].ToString();
if (signature.StartsWith("v1=")) signature = signature[3..];
byte[] hash = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes($"{timestamp}.{rawBody}"));
string expected = Convert.ToHexString(hash).ToLowerInvariant();
bool valid = CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(signature));
bool fresh = long.TryParse(timestamp, out long ts) && Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) <= 300;
if (!valid || !fresh) return Results.Unauthorized();
Enqueue(JsonDocument.Parse(rawBody));
return Results.Ok();
});
¿Qué secreto uso?
- Recomendado: una URL distinta por webhook (por ejemplo
/invoiced,/failed,/status), cada una con su secreto. - Si usas una sola URL, guarda todos los secretos y acepta el aviso si la firma coincide con alguno.
- No elijas el secreto por
data.associationId: cuando se usa el webhook de la raíz, ese campo trae la asociación del ticket, no la del webhook.
Cómo responder y procesar
- Responde rápido. Solo cuenta como entregado un
2xxen menos de 10 segundos. Guarda el aviso, responde y procésalo después. - Todo lo demás es un fallo: 3xx, 4xx, 5xx, timeout o error de conexión. Fotofacturas reintentará (ver sección 8).
- Descarta duplicados. Un evento puede llegar más de una vez (por reintentos o reenvíos). Guarda el
eventIdy descarta los que ya procesaste. - Ordena por
occurredAt. Los avisos pueden llegar desordenados: el estatus vigente de un ticket es el del aviso con eloccurredAtmás reciente. Si tienes duda, confírmalo conGetTicketById. Unticket.failedno es definitivo. - Pide el detalle con la API. Al recibir
ticket.invoiced, llama aGetTicketByIdpara obtener el monto y los enlaces al PDF y XML. Esos enlaces son temporales. - Los primeros 2000 caracteres de tu respuesta se guardan en el historial del evento; un JSON corto como
{"ok":true}ayuda al diagnóstico.
Entrega y reintentos
Los eventos se envían unos segundos después del cambio de estatus. Si un envío falla, se reintenta:
| Intento | Cuándo |
|---|---|
| 1 | Unos segundos después del cambio de estatus. |
| 2 | 1 minuto después del intento 1. |
| 3 | 5 minutos después del intento 2. |
| 4 | 1 hora después del intento 3. |
| 5 | 6 horas después del intento 4. |
Si los 5 intentos fallan (unas 7 horas), el evento queda fallido y ya no se reintenta solo.
Estatus de un evento
status | Nombre | En la web app business | Significado |
|---|---|---|---|
| 1 | PENDING | Pendiente | Pendiente de envío o esperando reintento. |
| 2 | DELIVERED | Entregado | Tu servidor respondió 2xx. |
| 3 | FAILED | Fallido | Se agotaron los intentos o el webhook se desactivó antes de entregarlo. |
Qué pasa si…
| Situación | Resultado |
|---|---|
| Cambias la URL | Los eventos pendientes y los reenvíos se mandan a la URL nueva. |
| Regeneras el secreto | El anterior deja de servir de inmediato; los siguientes envíos, incluidos los reintentos, se firman con el nuevo. Actualiza tu servidor antes, o acepta ambos secretos durante el cambio. |
| Desactivas el webhook | Ya no se generan eventos con ese webhook y los pendientes quedan FAILED en su siguiente intento, sin enviarse. Si no es de la raíz, los eventos nuevos usan el de la raíz (si está activo). |
| Reenvías un evento | Se envía con el mismo eventId y el mismo body, con X-FF-Timestamp y firma nuevos, y vuelve a tener 5 intentos. No se puede reenviar un evento PENDING. |
El historial de eventos se conserva 45 días.
Monitorear y reenviar
En la web app business, la pantalla Webhooks muestra el historial de envíos con filtros, el detalle de cada intento y el botón Reenviar evento (ver Web app business › Webhooks). Por API:
| Para… | Usa |
|---|---|
| Ver los webhooks de una asociación | GetWebhooks (nunca regresa el secreto). |
| Revisar qué se envió en un periodo | GetWebhookEvents con startDate y endDate (máximo 31 días). Filtros opcionales: associationId (asociación del ticket), userTicketId y status. Regresa hasta 5000 eventos, del más reciente al más antiguo. |
| Ver el body enviado y cada intento | GetWebhookEventById |
| Comprobar que tu endpoint responde | TestWebhook |
Revisa periódicamente los eventos fallidos (status=3) y reenvíalos cuando tu servidor esté disponible.
Referencia de APIs
URL base: https://api.fotofacturas.ai/B2B/WebhookV1/<API>. El detalle de cada petición está en API.
| API | Método | Parámetros | Respuesta (data) |
|---|---|---|---|
GetWebhooks | GET | associationId | Lista de webhooks: webhookSubscriptionId, associationId, eventType, eventName, url, isActive, fechas. |
SaveWebhook | POST (JSON) | associationId, eventType, url, isActive | webhookSubscriptionId y secret (solo al crear; null al actualizar). |
RegenerateWebhookSecret | POST (JSON) | webhookSubscriptionId | El secreto nuevo. |
TestWebhook | POST (JSON) | webhookSubscriptionId | isDelivered, statusCode, responseBody, error, durationMs. |
GetWebhookEvents | GET | startDate, endDate; opcionales associationId, userTicketId, status | totalCount, maxResults y la lista de eventos. |
GetWebhookEventById | GET | webhookEventId | El evento, la url actual, el payload enviado y los attempts. |
Solución de problemas
Mensajes de error
| Mensaje | Qué hacer |
|---|---|
El tipo de evento no es válido | Usa eventType 1, 2 o 3. |
La URL no es válida | Usa una URL completa, por ejemplo https://dominio.com/ruta. |
La URL debe usar HTTPS / La URL debe usar el puerto 443 | Usa https:// sin indicar otro puerto. |
La URL no puede incluir usuario ni contraseña | Quita usuario:contraseña@; protege tu endpoint con la firma. |
No se pudo resolver el dominio de la URL / La URL apunta a una dirección no permitida | Usa un dominio público que resuelva a IPs públicas. |
La URL no puede tener más de 1000 caracteres | Acorta la URL. |
La asociación no le pertenece | El associationId no es de tu cuenta. |
El webhook no existe / El evento no existe | El id no existe o no pertenece a tu cuenta. |
La fecha de inicio es obligatoria / La fecha de fin es obligatoria / La fecha de inicio no puede ser mayor a la fecha de fin | Revisa startDate y endDate. |
El rango no puede ser mayor a 31 días | Consulta periodos más cortos. |
El evento ya está pendiente de envío | Espera; el evento se enviará solo. |
No estas autorizado para usar esta API | Pide a Fotofacturas el permiso de esa API. |
No me llegan los avisos
- Revisa que el webhook exista y esté activo en la asociación del ticket o en la raíz (
GetWebhookso la pantalla Webhooks). - Ejecuta
TestWebhooky revisastatusCodeyerror. - Busca el ticket en el historial (
GetWebhookEventsconuserTicketId). Si no hay eventos, en el momento del cambio no había un webhook activo que aplicara. - Si hay eventos fallidos, revisa sus intentos (
GetWebhookEventById):
| Lo que ves en el intento | Causa probable |
|---|---|
statusCode 401 o 403 | Tu servidor rechaza la firma: revisa el body sin modificar y el secreto. |
statusCode 301, 302, 307 o 308 | Tu URL redirige. Registra la URL final. |
statusCode 404 | La ruta no existe en tu servidor. |
statusCode 5xx | Error en tu servidor. |
error: Tiempo de espera agotado (10 s) | Tu servidor tarda más de 10 segundos. Responde antes de procesar. |
error: El webhook está desactivado | El webhook se desactivó con el evento pendiente. |
Otro error (conexión, certificado, DNS) | Tu servidor no es accesible desde internet o su certificado TLS no es válido. |
La firma nunca coincide
- Calcula la firma sobre el body en bytes, tal como llegó (por ejemplo,
express.rawen Express). - Usa el secreto completo, incluido
whsec_, y el del webhook correcto. - Si regeneraste el secreto, actualízalo en tu servidor.
- Sincroniza tu reloj (NTP) si rechazas por antigüedad del timestamp.
Más dudas en Preguntas frecuentes.