Guide to receiving a notification in your systems every time a ticket changes status: how to set it up, what arrives, how to verify the signature and what to do when something fails.
For: technical teams at client companies. Terms (association, root association, collaborator, association ticket) are defined in Concepts. In the business web app, associations are called empresas (companies).
What a webhook is
A webhook is a POST request Fotofacturas sends to a URL of yours when a ticket changes status. That way you find out right away that a ticket was invoiced or rejected, without polling the API over and over.
- It applies to tickets from any channel (mobile app, web app, WhatsApp, embedded flow, business web app or Enterprise API), as long as there’s an active webhook that applies (see Which webhook is used for each ticket).
- The notification only carries identifiers and status. For the details (amount, PDF, XML), call
GetTicketByIdwith theuserTicketId.
Quick start
-
Publish an endpoint over HTTPS on port 443, reachable from the internet.
-
Register the webhook on the root association, from the business web app (Webhooks) or with
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 } -
Store the secret (
data.secret, starts withwhsec_). It’s only shown when the webhook is created. -
Verify the signature of every notification (see Verifying the signature) and respond
2xxin under 10 seconds. -
Test it with Probar in the business web app or with
TestWebhook. Your endpoint will receive apingevent.
To use the webhook APIs, your account needs their API permissions (see API reference). If you don’t have them, ask Fotofacturas.
Events
eventType | Name (X-FF-Event) | When it’s sent |
|---|---|---|
| 1 | ticket.invoiced | The ticket moved to 11 SIGNED (invoiced). |
| 2 | ticket.failed | The ticket moved to 10 UNSIGNABLE (not invoiceable). The reason is in ticketCodeId. |
| 3 | ticket.status_changed | The ticket changed to any status, including its creation (null → 1). |
- A single change can generate two events: when a ticket is invoiced, both
ticket.status_changedandticket.invoicedare generated, each with its owneventIdand sent to its own URL. - You’re only notified when the status changes. Not every ticket goes through every intermediate status.
- If you only care about the final result, subscribe to
ticket.invoicedandticket.failed.
Statuses are in Catalogs and Ticket flow.
Setup
One webhook per event and per association
A webhook is the combination of an association, an event, a URL and a secret. Each association can have one per event (three in total).
SaveWebhookcreates the webhook or, if it already exists, updates itsurlandisActive. The secret doesn’t change when you update.- Each webhook has its own secret.
- Webhooks aren’t deleted; they’re deactivated with
isActive: false(in the business web app, by turning off Enviar notificaciones).
Which webhook is used for each ticket
For each event, Fotofacturas looks for:
- The active webhook for that event on the ticket’s association.
- If there isn’t one, the active webhook for that event on the root association.
- If there isn’t one there either, that event isn’t generated.
- Webhooks on intermediate associations aren’t used. With Root → North Region → Monterrey Branch and a webhook only on North Region, Monterrey Branch tickets use the root’s.
- If you deactivate the webhook of an association that isn’t the root, its tickets switch to the root’s (if it’s active).
- Simplest option: configure webhooks only on the root association.
data.associationIdtells you which association each ticket belongs to.
URL requirements
| Requirement | Message if not met |
|---|---|
https:// scheme | La URL debe usar HTTPS |
| Port 443 | La URL debe usar el puerto 443 |
| No username or password | La URL no puede incluir usuario ni contraseña |
| Domain that resolves only to public IPs | No se pudo resolver el dominio de la URL / La URL apunta a una dirección no permitida |
| Maximum 1000 characters | La URL no puede tener más de 1000 caracteres |
IPs are also validated on every send. Redirects aren’t followed.
When they start arriving
Events are generated at the moment of the status change and only if there’s an active webhook that applies at that moment. Creating or activating a webhook doesn’t generate notifications for earlier changes; to catch up, use the reporting APIs.
The notification
Request
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 | Content |
|---|---|
X-FF-Event | ticket.invoiced, ticket.failed, ticket.status_changed or ping. |
X-FF-Event-Id | The body’s eventId, or ping for tests. |
X-FF-Timestamp | Time of sending in Unix seconds (UTC). It changes on every attempt. |
X-FF-Signature | HMAC-SHA256 signature with the v1= prefix. |
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
}
}
| Field | Type | Description |
|---|---|---|
eventId | long | Unique event id. Use it to discard duplicates. |
eventType | string | Event name (same as X-FF-Event). |
occurredAt | date | When the status changed, in Mexico time. |
data.userTicketId | int | Ticket id. |
data.userId | int | null | Collaborator who owns the ticket. null for association tickets. |
data.associationId | int | The ticket’s association (not necessarily the webhook’s). |
data.ticketStatusId / data.ticketStatus | int / string | New status. |
data.previousTicketStatusId | int | null | Previous status. null when the ticket was just created. |
data.ticketCodeId | int | Ticket code at that moment (GetTicketCodes catalog). |
Test event (ping)
{
"eventId": null,
"eventType": "ping",
"occurredAt": "2026-10-13T10:00:00",
"data": {
"webhookSubscriptionId": 7,
"associationId": 41,
"subscribedEventType": "ticket.invoiced"
}
}
It’s signed the same way as the others. It’s sent even if the webhook is deactivated, isn’t retried and isn’t kept in the history. Respond 2xx even if you don’t process it.
Verifying the signature
Always verify the signature before processing the notification:
- Read the exact body, in bytes, before parsing it as JSON.
- Build the signed text:
X-FF-Timestamp+.+ body. Example:1791900922.{"eventId":98123,...}. - Compute the
HMAC-SHA256of that text with the full secret (includingwhsec_) as the key, in lowercase hexadecimal. - Compare it in constant time with
X-FF-Signaturewithout thev1=prefix. - Reject the notification if
X-FF-Timestampdiffers by more than 5 minutes from your clock.
If the signature isn’t valid, respond 401 and don’t process the notification.
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();
});
Which secret do I use?
- Recommended: a different URL per webhook (for example
/invoiced,/failed,/status), each with its own secret. - If you use a single URL, store all the secrets and accept the notification if the signature matches any of them.
- Don’t pick the secret by
data.associationId: when the root’s webhook is used, that field carries the ticket’s association, not the webhook’s.
Responding and processing
- Respond fast. Only a
2xxin under 10 seconds counts as delivered. Store the notification, respond, and process it afterwards. - Everything else is a failure: 3xx, 4xx, 5xx, timeout or connection error. Fotofacturas will retry (see Delivery and retries).
- Discard duplicates. An event can arrive more than once (because of retries or resends). Store the
eventIdand discard the ones you’ve already processed. - Order by
occurredAt. Notifications can arrive out of order: a ticket’s current status is the one in the notification with the most recentoccurredAt. If in doubt, confirm withGetTicketById. Aticket.failedisn’t final. - Get the details from the API. When you receive
ticket.invoiced, callGetTicketByIdto get the amount and the links to the PDF and XML. Those links are temporary. - The first 2000 characters of your response are saved in the event history; a short JSON such as
{"ok":true}helps with troubleshooting.
Delivery and retries
Events are sent a few seconds after the status change. If a send fails, it’s retried:
| Attempt | When |
|---|---|
| 1 | A few seconds after the status change. |
| 2 | 1 minute after attempt 1. |
| 3 | 5 minutes after attempt 2. |
| 4 | 1 hour after attempt 3. |
| 5 | 6 hours after attempt 4. |
If all 5 attempts fail (about 7 hours), the event is marked failed and isn’t retried automatically anymore.
Event status
status | Name | In the business web app | Meaning |
|---|---|---|---|
| 1 | PENDING | Pendiente | Waiting to be sent or waiting for a retry. |
| 2 | DELIVERED | Entregado | Your server responded 2xx. |
| 3 | FAILED | Fallido | Attempts ran out, or the webhook was deactivated before delivery. |
What happens if…
| Situation | Result |
|---|---|
| You change the URL | Pending events and resends go to the new URL. |
| You regenerate the secret | The old one stops working immediately; subsequent sends, including retries, are signed with the new one. Update your server first, or accept both secrets during the switch. |
| You deactivate the webhook | No more events are generated with that webhook, and pending ones become FAILED on their next attempt without being sent. If it isn’t the root’s, new events use the root’s (if it’s active). |
| You resend an event | It’s sent with the same eventId and the same body, with a new X-FF-Timestamp and signature, and gets 5 attempts again. A PENDING event can’t be resent. |
Event history is kept for 45 days.
Monitoring and resending
In the business web app, the Webhooks screen shows the delivery history with filters, the detail of each attempt and the Reenviar evento button (see Business web app › Webhooks). Through the API:
| To… | Use |
|---|---|
| See an association’s webhooks | GetWebhooks (never returns the secret). |
| Check what was sent in a period | GetWebhookEvents with startDate and endDate (31 days max). Optional filters: associationId (the ticket’s association), userTicketId and status. Returns up to 5000 events, newest first. |
| See the body sent and each attempt | GetWebhookEventById |
| Check that your endpoint responds | TestWebhook |
Review failed events (status=3) regularly and resend them once your server is available.
API reference
Base URL: https://api.fotofacturas.ai/B2B/WebhookV1/<API>. The details of each request are in API.
| API | Method | Parameters | Response (data) |
|---|---|---|---|
GetWebhooks | GET | associationId | List of webhooks: webhookSubscriptionId, associationId, eventType, eventName, url, isActive, dates. |
SaveWebhook | POST (JSON) | associationId, eventType, url, isActive | webhookSubscriptionId and secret (only on create; null on update). |
RegenerateWebhookSecret | POST (JSON) | webhookSubscriptionId | The new secret. |
TestWebhook | POST (JSON) | webhookSubscriptionId | isDelivered, statusCode, responseBody, error, durationMs. |
GetWebhookEvents | GET | startDate, endDate; optional associationId, userTicketId, status | totalCount, maxResults and the list of events. |
GetWebhookEventById | GET | webhookEventId | The event, the current url, the payload sent and the attempts. |
Troubleshooting
Error messages
| Message | What to do |
|---|---|
El tipo de evento no es válido | Use eventType 1, 2 or 3. |
La URL no es válida | Use a full URL, for example https://dominio.com/ruta. |
La URL debe usar HTTPS / La URL debe usar el puerto 443 | Use https:// without specifying another port. |
La URL no puede incluir usuario ni contraseña | Remove user:password@; protect your endpoint with the signature. |
No se pudo resolver el dominio de la URL / La URL apunta a una dirección no permitida | Use a public domain that resolves to public IPs. |
La URL no puede tener más de 1000 caracteres | Shorten the URL. |
La asociación no le pertenece | The associationId isn’t from your account. |
El webhook no existe / El evento no existe | The id doesn’t exist or doesn’t belong to your account. |
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 | Check startDate and endDate. |
El rango no puede ser mayor a 31 días | Query shorter periods. |
El evento ya está pendiente de envío | Wait; the event will be sent on its own. |
No estas autorizado para usar esta API | Ask Fotofacturas for that API’s permission. |
I’m not receiving notifications
- Check that the webhook exists and is active on the ticket’s association or on the root (
GetWebhooksor the Webhooks screen). - Run
TestWebhookand checkstatusCodeanderror. - Look up the ticket in the history (
GetWebhookEventswithuserTicketId). If there are no events, there was no active webhook that applied at the time of the change. - If there are failed events, review their attempts (
GetWebhookEventById):
| What you see in the attempt | Likely cause |
|---|---|
statusCode 401 or 403 | Your server rejects the signature: check the unmodified body and the secret. |
statusCode 301, 302, 307 or 308 | Your URL redirects. Register the final URL. |
statusCode 404 | The path doesn’t exist on your server. |
statusCode 5xx | Error on your server. |
error: Tiempo de espera agotado (10 s) | Your server takes longer than 10 seconds. Respond before processing. |
error: El webhook está desactivado | The webhook was deactivated while the event was pending. |
Any other error (connection, certificate, DNS) | Your server isn’t reachable from the internet or its TLS certificate isn’t valid. |
The signature never matches
- Compute the signature over the body in bytes, exactly as it arrived (for example,
express.rawin Express). - Use the full secret, including
whsec_, and the one for the right webhook. - If you regenerated the secret, update it on your server.
- Sync your clock (NTP) if you’re rejecting notifications because of the timestamp age.
More questions in Frequently asked questions.