Integración

Webhooks

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 GetTicketById con el userTicketId.

Inicio rápido

  1. Publica un endpoint HTTPS en el puerto 443, accesible desde internet.

  2. 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
    }
  3. Guarda el secreto (data.secret, empieza con whsec_). Solo se muestra al crear el webhook.

  4. Verifica la firma de cada aviso (ver sección 6) y responde 2xx en menos de 10 segundos.

  5. Prueba con Probar en la web app business o con TestWebhook. Tu endpoint recibirá un evento ping.

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

eventTypeNombre (X-FF-Event)Cuándo se envía
1ticket.invoicedEl ticket pasó a 11 SIGNED (facturado).
2ticket.failedEl ticket pasó a 10 UNSIGNABLE (no facturable). El motivo está en ticketCodeId.
3ticket.status_changedEl ticket cambió a cualquier estatus, incluida su creación (null → 1).
  • Un mismo cambio puede generar dos eventos: al facturarse se generan ticket.status_changed y ticket.invoiced, cada uno con su propio eventId y 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.invoiced y ticket.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).

  • SaveWebhook crea el webhook o, si ya existe, actualiza su url e isActive. 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:

  1. El webhook activo de ese evento en la asociación del ticket.
  2. Si no hay, el webhook activo de ese evento en la asociación raíz.
  3. 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.associationId te dice de qué asociación es cada ticket.

Requisitos de la URL

RequisitoMensaje si no se cumple
Esquema https://La URL debe usar HTTPS
Puerto 443La URL debe usar el puerto 443
Sin usuario ni contraseñaLa URL no puede incluir usuario ni contraseña
Dominio que resuelva solo a IPs públicasNo se pudo resolver el dominio de la URL / La URL apunta a una dirección no permitida
Máximo 1000 caracteresLa 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
HeaderContenido
X-FF-Eventticket.invoiced, ticket.failed, ticket.status_changed o ping.
X-FF-Event-IdEl eventId del body, o ping en las pruebas.
X-FF-TimestampMomento del envío en segundos Unix (UTC). Cambia en cada intento.
X-FF-SignatureFirma 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
  }
}
CampoTipoDescripción
eventIdlongId único del evento. Úsalo para descartar duplicados.
eventTypestringNombre del evento (igual a X-FF-Event).
occurredAtfechaCuándo cambió el estatus, en hora de México.
data.userTicketIdintId del ticket.
data.userIdint | nullColaborador dueño del ticket. null en los tickets de asociación.
data.associationIdintAsociación del ticket (no necesariamente la del webhook).
data.ticketStatusId / data.ticketStatusint / stringEstatus nuevo.
data.previousTicketStatusIdint | nullEstatus anterior. null cuando el ticket se acaba de crear.
data.ticketCodeIdintCó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:

  1. Lee el body exacto, en bytes, antes de convertirlo a JSON.
  2. Arma el texto firmado: X-FF-Timestamp + . + body. Ejemplo: 1791900922.{"eventId":98123,...}.
  3. Calcula HMAC-SHA256 de ese texto con el secreto completo (incluido whsec_) como llave, en hexadecimal minúsculas.
  4. Compáralo en tiempo constante con X-FF-Signature sin el prefijo v1=.
  5. Rechaza el aviso si X-FF-Timestamp difiere 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 2xx en 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 eventId y descarta los que ya procesaste.
  • Ordena por occurredAt. Los avisos pueden llegar desordenados: el estatus vigente de un ticket es el del aviso con el occurredAt más reciente. Si tienes duda, confírmalo con GetTicketById. Un ticket.failed no es definitivo.
  • Pide el detalle con la API. Al recibir ticket.invoiced, llama a GetTicketById para 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:

IntentoCuándo
1Unos segundos después del cambio de estatus.
21 minuto después del intento 1.
35 minutos después del intento 2.
41 hora después del intento 3.
56 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

statusNombreEn la web app businessSignificado
1PENDINGPendientePendiente de envío o esperando reintento.
2DELIVEREDEntregadoTu servidor respondió 2xx.
3FAILEDFallidoSe agotaron los intentos o el webhook se desactivó antes de entregarlo.

Qué pasa si…

SituaciónResultado
Cambias la URLLos eventos pendientes y los reenvíos se mandan a la URL nueva.
Regeneras el secretoEl 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 webhookYa 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 eventoSe 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ónGetWebhooks (nunca regresa el secreto).
Revisar qué se envió en un periodoGetWebhookEvents 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 intentoGetWebhookEventById
Comprobar que tu endpoint respondeTestWebhook

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.

APIMétodoParámetrosRespuesta (data)
GetWebhooksGETassociationIdLista de webhooks: webhookSubscriptionId, associationId, eventType, eventName, url, isActive, fechas.
SaveWebhookPOST (JSON)associationId, eventType, url, isActivewebhookSubscriptionId y secret (solo al crear; null al actualizar).
RegenerateWebhookSecretPOST (JSON)webhookSubscriptionIdEl secreto nuevo.
TestWebhookPOST (JSON)webhookSubscriptionIdisDelivered, statusCode, responseBody, error, durationMs.
GetWebhookEventsGETstartDate, endDate; opcionales associationId, userTicketId, statustotalCount, maxResults y la lista de eventos.
GetWebhookEventByIdGETwebhookEventIdEl evento, la url actual, el payload enviado y los attempts.

Solución de problemas

Mensajes de error

MensajeQué hacer
El tipo de evento no es válidoUsa eventType 1, 2 o 3.
La URL no es válidaUsa una URL completa, por ejemplo https://dominio.com/ruta.
La URL debe usar HTTPS / La URL debe usar el puerto 443Usa https:// sin indicar otro puerto.
La URL no puede incluir usuario ni contraseñaQuita 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 permitidaUsa un dominio público que resuelva a IPs públicas.
La URL no puede tener más de 1000 caracteresAcorta la URL.
La asociación no le perteneceEl associationId no es de tu cuenta.
El webhook no existe / El evento no existeEl 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 finRevisa startDate y endDate.
El rango no puede ser mayor a 31 díasConsulta periodos más cortos.
El evento ya está pendiente de envíoEspera; el evento se enviará solo.
No estas autorizado para usar esta APIPide a Fotofacturas el permiso de esa API.

No me llegan los avisos

  1. Revisa que el webhook exista y esté activo en la asociación del ticket o en la raíz (GetWebhooks o la pantalla Webhooks).
  2. Ejecuta TestWebhook y revisa statusCode y error.
  3. Busca el ticket en el historial (GetWebhookEvents con userTicketId). Si no hay eventos, en el momento del cambio no había un webhook activo que aplicara.
  4. Si hay eventos fallidos, revisa sus intentos (GetWebhookEventById):
Lo que ves en el intentoCausa probable
statusCode 401 o 403Tu servidor rechaza la firma: revisa el body sin modificar y el secreto.
statusCode 301, 302, 307 o 308Tu URL redirige. Registra la URL final.
statusCode 404La ruta no existe en tu servidor.
statusCode 5xxError 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á desactivadoEl 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.raw en 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.