Integration

Webhooks

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 GetTicketById with the userTicketId.

Quick start

  1. Publish an endpoint over HTTPS on port 443, reachable from the internet.

  2. 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
    }
  3. Store the secret (data.secret, starts with whsec_). It’s only shown when the webhook is created.

  4. Verify the signature of every notification (see Verifying the signature) and respond 2xx in under 10 seconds.

  5. Test it with Probar in the business web app or with TestWebhook. Your endpoint will receive a ping event.

To use the webhook APIs, your account needs their API permissions (see API reference). If you don’t have them, ask Fotofacturas.


Events

eventTypeName (X-FF-Event)When it’s sent
1ticket.invoicedThe ticket moved to 11 SIGNED (invoiced).
2ticket.failedThe ticket moved to 10 UNSIGNABLE (not invoiceable). The reason is in ticketCodeId.
3ticket.status_changedThe 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_changed and ticket.invoiced are generated, each with its own eventId and 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.invoiced and ticket.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).

  • SaveWebhook creates the webhook or, if it already exists, updates its url and isActive. 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:

  1. The active webhook for that event on the ticket’s association.
  2. If there isn’t one, the active webhook for that event on the root association.
  3. 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.associationId tells you which association each ticket belongs to.

URL requirements

RequirementMessage if not met
https:// schemeLa URL debe usar HTTPS
Port 443La URL debe usar el puerto 443
No username or passwordLa URL no puede incluir usuario ni contraseña
Domain that resolves only to public IPsNo se pudo resolver el dominio de la URL / La URL apunta a una dirección no permitida
Maximum 1000 charactersLa 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
HeaderContent
X-FF-Eventticket.invoiced, ticket.failed, ticket.status_changed or ping.
X-FF-Event-IdThe body’s eventId, or ping for tests.
X-FF-TimestampTime of sending in Unix seconds (UTC). It changes on every attempt.
X-FF-SignatureHMAC-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
  }
}
FieldTypeDescription
eventIdlongUnique event id. Use it to discard duplicates.
eventTypestringEvent name (same as X-FF-Event).
occurredAtdateWhen the status changed, in Mexico time.
data.userTicketIdintTicket id.
data.userIdint | nullCollaborator who owns the ticket. null for association tickets.
data.associationIdintThe ticket’s association (not necessarily the webhook’s).
data.ticketStatusId / data.ticketStatusint / stringNew status.
data.previousTicketStatusIdint | nullPrevious status. null when the ticket was just created.
data.ticketCodeIdintTicket 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:

  1. Read the exact body, in bytes, before parsing it as JSON.
  2. Build the signed text: X-FF-Timestamp + . + body. Example: 1791900922.{"eventId":98123,...}.
  3. Compute the HMAC-SHA256 of that text with the full secret (including whsec_) as the key, in lowercase hexadecimal.
  4. Compare it in constant time with X-FF-Signature without the v1= prefix.
  5. Reject the notification if X-FF-Timestamp differs 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 2xx in 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 eventId and 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 recent occurredAt. If in doubt, confirm with GetTicketById. A ticket.failed isn’t final.
  • Get the details from the API. When you receive ticket.invoiced, call GetTicketById to 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:

AttemptWhen
1A few seconds after the status change.
21 minute after attempt 1.
35 minutes after attempt 2.
41 hour after attempt 3.
56 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

statusNameIn the business web appMeaning
1PENDINGPendienteWaiting to be sent or waiting for a retry.
2DELIVEREDEntregadoYour server responded 2xx.
3FAILEDFallidoAttempts ran out, or the webhook was deactivated before delivery.

What happens if…

SituationResult
You change the URLPending events and resends go to the new URL.
You regenerate the secretThe 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 webhookNo 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 eventIt’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 webhooksGetWebhooks (never returns the secret).
Check what was sent in a periodGetWebhookEvents 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 attemptGetWebhookEventById
Check that your endpoint respondsTestWebhook

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.

APIMethodParametersResponse (data)
GetWebhooksGETassociationIdList of webhooks: webhookSubscriptionId, associationId, eventType, eventName, url, isActive, dates.
SaveWebhookPOST (JSON)associationId, eventType, url, isActivewebhookSubscriptionId and secret (only on create; null on update).
RegenerateWebhookSecretPOST (JSON)webhookSubscriptionIdThe new secret.
TestWebhookPOST (JSON)webhookSubscriptionIdisDelivered, statusCode, responseBody, error, durationMs.
GetWebhookEventsGETstartDate, endDate; optional associationId, userTicketId, statustotalCount, maxResults and the list of events.
GetWebhookEventByIdGETwebhookEventIdThe event, the current url, the payload sent and the attempts.

Troubleshooting

Error messages

MessageWhat to do
El tipo de evento no es válidoUse eventType 1, 2 or 3.
La URL no es válidaUse a full URL, for example https://dominio.com/ruta.
La URL debe usar HTTPS / La URL debe usar el puerto 443Use https:// without specifying another port.
La URL no puede incluir usuario ni contraseñaRemove 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 permitidaUse a public domain that resolves to public IPs.
La URL no puede tener más de 1000 caracteresShorten the URL.
La asociación no le perteneceThe associationId isn’t from your account.
El webhook no existe / El evento no existeThe 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 finCheck startDate and endDate.
El rango no puede ser mayor a 31 díasQuery shorter periods.
El evento ya está pendiente de envíoWait; the event will be sent on its own.
No estas autorizado para usar esta APIAsk Fotofacturas for that API’s permission.

I’m not receiving notifications

  1. Check that the webhook exists and is active on the ticket’s association or on the root (GetWebhooks or the Webhooks screen).
  2. Run TestWebhook and check statusCode and error.
  3. Look up the ticket in the history (GetWebhookEvents with userTicketId). If there are no events, there was no active webhook that applied at the time of the change.
  4. If there are failed events, review their attempts (GetWebhookEventById):
What you see in the attemptLikely cause
statusCode 401 or 403Your server rejects the signature: check the unmodified body and the secret.
statusCode 301, 302, 307 or 308Your URL redirects. Register the final URL.
statusCode 404The path doesn’t exist on your server.
statusCode 5xxError on your server.
error: Tiempo de espera agotado (10 s)Your server takes longer than 10 seconds. Respond before processing.
error: El webhook está desactivadoThe 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.raw in 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.