Webhooks

Webhooks notify your system as an invoice moves through cancellation or when a bulk download (the SAT service that delivers the CFDIs a taxpayer issued and received) changes, so you don't have to poll the API every few minutes. You register an https URL, choose the event types, and Fiscalapi sends it a signed POST each time one occurs.

How it works

  1. You register a webhook: the URL that receives the events, the types it subscribes to, and, optionally, the people (person: any individual or legal entity registered in your tenant) whose events it receives. You save its signing secret.
  2. A resource changes status, for example an invoice goes from Cancelando (canceling) to Cancelado (canceled), and Fiscalapi records an event.
  3. Fiscalapi sends the event by POST to every enabled webhook that subscribes to it, signed with that webhook's secret.
  4. Your endpoint verifies the signature, responds with a 2xx, and processes the event afterward. If it doesn't respond with a 2xx, Fiscalapi keeps retrying for about 75 hours.

The event only says which resource changed and which status it reached; it carries no tax details. For the details, fetch the resource with the URL that comes in the event.

Webhooks are included in every tenant (the space in Fiscalapi that holds your issuers, recipients, credits, and data). Managing them requires the Webhooks.Manage permission. The owner (the person who created the tenant) and the other people who sign in to your tenant have it. The API accepts these requests with your API key or with your dashboard session.


Event types

Each type names the status the resource reached, in the form resource.status. Fiscalapi emits one event per transition.

TypeResourceWhen it happens
invoice.cancellation_requestedInvoiceThe PAC (Proveedor Autorizado de Certificación, a SAT-authorized provider that stamps CFDIs) accepted the cancellation request: the invoice goes from Vigente (active) to Cancelando (canceling).
invoice.canceledInvoiceThe SAT (Mexico's tax authority, Servicio de Administración Tributaria) confirmed the cancellation: the invoice goes from Cancelando (canceling) to Cancelado (canceled).
invoice.cancellation_rejectedInvoiceThe cancellation was rejected or isn't allowed: the invoice goes back from Cancelando (canceling) to Vigente (active).
download_request.completedDownload requestThe request finished and its packages are ready to download.
download_request.abandonedDownload requestFiscalapi abandoned the request.
download_rule.suspendedDownload ruleFiscalapi suspended the rule, for example because of an expired or missing e.firma (the SAT advanced electronic signature that identifies a taxpayer, formerly FIEL).
webhook.testWebhookYou tested the webhook. Only the webhook you tested receives it, and you can't subscribe to it.

download_request.abandoned doesn't imply an error: the reason is in the request's GET response. A common cause is that the SAT finds no documents that match the criteria. See the details with Get a request by ID and the request lifecycle.

Ignore event types you don't recognize: new types can be added without changing the envelope. A new type arrives with the same shape; respond with a 2xx and discard it.

Status codes

The status and previousStatus fields carry the public API v4 codes, the same ones the resource's GET returns:

ResourceCodeStatus
Invoice"V"Vigente (active)
Invoice"C"Cancelando (canceling): the SAT hasn't confirmed the cancellation yet.
Invoice"X"Cancelado (canceled)
Download request"1"Esperando SAT (waiting for the SAT)
Download request"2"Esperando API (waiting for the API)
Download request"3"Completada (completed)
Download request"-1"Abandonada (abandoned)
Download rule"1"Pendiente (pending)
Download rule"2"Aprobada (approved)
Download rule"3"Rechazada (rejected)
Download rule"4"Suspendida (suspended)
Download rule"-1"Abandonada (abandoned)

The previous status depends on the path the resource took. For example, a request is abandoned from Esperando SAT (waiting for the SAT) or from Esperando API (waiting for the API), and the simulated request in the test environment reaches Completada (completed) with no previous status. Treat both fields as text that can be null.


The event

Every event has the same shape: the envelope. No field belongs to a single type; only the values change between types.

The POST body is the envelope as compact UTF-8 JSON. Fields that don't apply are sent as null; they're never omitted.

Properties

  • Name
    id
    Type
    string?
    Description

    The event ID. It's the same value as the webhook-id header and doesn't change between retries or resends.

  • Name
    type
    Type
    string?
    Description

    The event type. See Event types. Treat it as free text: new types can appear.

  • Name
    createdAt
    Type
    string?
    Description

    When the change occurred, in ISO 8601 and UTC, to the second, with a trailing Z. It doesn't depend on the X-TIME-ZONE header.

  • Name
    personId
    Type
    string?
    Description

    The person who owns the resource: the invoice issuer or the download rule's person. It can be null.

  • Name
    data
    Type
    object?

    The resource that changed and its statuses.

  • Name
    relatedObject
    Type
    object?

    The resource the event refers to.

  • Name
    id
    Type
    string?
    Description

    The resource ID, the same one you use in the API.

  • Name
    type
    Type
    string?
    expandable
    Description

    The resource type. Treat it as free text.

    Type
    enum:
    Values
    "invoice""download_request""download_rule"
  • Name
    url
    Type
    string?
    Description

    The relative path of the resource's v4 GET, for example /api/v4/invoices/ followed by the ID. Prepend your environment's base URL to fetch it.

  • Name
    status
    Type
    string?
    Description

    The code of the status the resource reached, or null. See Status codes.

  • Name
    previousStatus
    Type
    string?
    Description

    The code of the previous status, or null if it doesn't apply.

Events

{
  "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "type": "invoice.canceled",
  "createdAt": "2026-10-12T18:47:53Z",
  "personId": "4f6a2c1e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
  "data": {
    "relatedObject": {
      "id": "0b6c1f2e-7d8a-4b9c-8e1f-2a3b4c5d6e7f",
      "type": "invoice",
      "url": "/api/v4/invoices/0b6c1f2e-7d8a-4b9c-8e1f-2a3b4c5d6e7f"
    },
    "status": "X",
    "previousStatus": "C"
  }
}

The examples are formatted for readability; the actual body arrives on a single line, without spaces. Because the URL is relative, the same event is identical in test and production. The base URL is https://test.fiscalapi.com in test and https://live.fiscalapi.com in production.

personId can be null, as in an invoice by value (you send every issuer, recipient, and item value in the request), and then the event doesn't reach webhooks with a people filter. An invoice by value has no issuer registered as a person in your tenant, so only webhooks without a people filter receive its event. If you issue invoices by value and want those events, register a webhook without a filter.


Register a webhook

A webhook belongs to your tenant and receives the events of all its people, unless you limit it to some of them. You can have up to 16 URL webhooks per tenant.

FieldRule
URLhttps, absolute, with no username or password, and up to 2,048 characters. It can't point to a private, local, or reserved address, or to localhost.
DescriptionOptional, up to 256 characters. It helps you recognize the webhook in the dashboard and in notifications.
Event typesAt least one, or only the "*" wildcard to receive all of them, including types added later. The wildcard can't be combined with other types.
PeopleOptional, up to 100. With no people, the webhook receives the events of all of them. A person that isn't in your tenant is rejected.

To receive events on your computer, use the CLI: see Test your endpoint.

From the dashboard

Go to Developers > Webhooks and click Agregar webhook (Add webhook). Enter the URL, the description, the events (Todos (All) or the types you choose), and, optionally, the people. When you save, the dashboard shows the signing secret with the notice "Guarda este secreto de firma. Lo usarás para verificar que los eventos vienen de FiscalAPI." (Save this signing secret. You'll use it to verify that the events come from Fiscalapi.) Click Copiar (Copy) and store it in your endpoint's secure configuration.

The Webhooks page has these actions:

ActionWhereWhat it does
Editar (Edit), Deshabilitar (Disable), Habilitar (Enable), and Eliminar (Delete)The webhook's Acciones (Actions) menuThey change the webhook. See Replace a webhook.
Revelar secreto (Reveal secret)The webhook's Acciones (Actions) menuShows the current secret without changing it. Every reveal is logged.
Rotar secreto (Rotate secret)The webhook's Acciones (Actions) menuAsks for confirmation and shows the new secret. See Rotate the secret.
Entregas (Deliveries)The Entregas (Deliveries) table, when you select a webhookThe webhook's delivery log: Evento (Event), Tipo (Type), Estado (Status), Intentos (Attempts), Último código (Last code), Motivo (Reason), Latencia (Latency), Extracto de la respuesta (Response excerpt), and Próximo intento (Next attempt). Recargar (Reload) refreshes it.
Reenviar (Resend)A delivery's Acciones (Actions) menuResends its event to the selected webhook, in a single attempt.
Enviar evento de prueba (Send test event)The Entregas (Deliveries) table toolbarSends a test event to the selected webhook. See Test your endpoint.
Eliminar (Delete)The Escuchas de CLI (CLI listeners) tableListeners started with fiscalapi listen appear here and are read-only: you can only delete them.

Reenviar (Resend) and Enviar evento de prueba (Send test event) only appear when the webhook is enabled. Revelar secreto (Reveal secret) and Rotar secreto (Rotate secret) don't appear while someone from support accesses your tenant on your behalf.

With the API

Register the webhook with Create a webhook. The response includes the secret in secret; with the API key, you can only get it again by rotating it, so save it when you receive the response. Then test it with Test a webhook and check the result in List deliveries.

The secret is the whsec_ prefix followed by 44 base64 characters: 32 random bytes, with an equals sign at the end. Store it in full, with its prefix.


Verify the signature

Every delivery follows the open Standard Webhooks specification and carries three headers:

HeaderContent
webhook-idThe event ID, equal to the envelope's id and the same on every retry.
webhook-timestampWhen that attempt was sent, in Unix seconds.
webhook-signatureOne or more signatures separated by a space, each in the format v1, a comma, and the base64 signature.

The signature is the only proof that an event is authentic. Fiscalapi doesn't publish its servers' IP addresses or recommend filtering deliveries by IP: those addresses can change without notice. Reject every delivery whose signature doesn't match the one you compute.

The signature is a base64 HMAC-SHA256 over the text {id}.{timestamp}.{body}: the event ID, a period, the timestamp, another period, and the raw body, exactly as they arrive. The HMAC key is the secret without the whsec_ prefix, decoded from base64.

To verify a delivery:

  1. Read the raw body, exactly the bytes that arrived, before your framework turns it into an object.
  2. Reject the delivery if the timestamp is more than 5 minutes off from your clock. That way, no one can replay a captured delivery later.
  3. Compute the signature with each current secret. During a rotation, you have two.
  4. Accept the delivery if any v1 signature in the header matches yours. Compare in constant time, never with a regular string comparison.
  5. Only then parse the body as JSON and process it.

Verify against the raw body. If your framework parses the JSON into an object and you serialize it again, the text changes (whitespace, order, or escaping), and the signature doesn't match even if the event is legitimate.

With the SDK

The official SDKs do these steps for you. Their verification function takes the delivery headers, the raw body, and your secret (or a list of secrets during a rotation), and returns the verified event:

Receive an event

using System.Text;
using System.Threading.Channels;
using Fiscalapi.Models;
using Fiscalapi.Webhooks;

// ASP.NET Core. The current secret and, during a rotation, the previous one too.
var secrets = builder.Configuration.GetSection("FiscalApi:WebhookSecrets").Get<string[]>()!;
var events = Channel.CreateUnbounded<WebhookEvent>(); // for illustration: use a durable queue in production

app.MapPost("/webhooks/fiscalapi", async (HttpRequest request) =>
{
    // The raw body, exactly as it arrived.
    using var reader = new StreamReader(request.Body, Encoding.UTF8);
    var body = await reader.ReadToEndAsync();
    var headers = request.Headers.ToDictionary(h => h.Key, h => h.Value.ToString());

    WebhookEvent webhookEvent;
    try
    {
        webhookEvent = Webhook.Verify(headers, body, secrets);
    }
    catch (WebhookVerificationException)
    {
        return Results.BadRequest(); // invalid signature, missing header, or timestamp outside the tolerance
    }

    await events.Writer.WriteAsync(webhookEvent); // process it later, outside the request
    return Results.Ok();
});

Verification compares in constant time, accepts any valid v1 signature in the header, and rejects a timestamp more than 5 minutes off. If the delivery fails verification, the function throws the verification exception: respond with 400 and don't process the delivery. A miscopied secret or an empty list throws a configuration error, which never includes the secret's value.

SDKFunctionDelivery that fails verificationMisconfigured secret
.NETWebhook.VerifyWebhookVerificationExceptionArgumentException
Node.jsWebhook.verifyWebhookVerificationErrorTypeError
Pythonfiscalapi.webhooks.verifyWebhookVerificationExceptionValueError
JavaWebhook.verifyWebhookVerificationExceptionIllegalArgumentException
PHPWebhook::verifyWebhookVerificationExceptionInvalidArgumentException

The classes live in Fiscalapi.Webhooks in .NET, in com.fiscalapi.webhooks in Java, and in Fiscalapi\Webhooks in PHP. In Node.js, the fiscalapi package exports them.

By hand

Without the SDK, any Standard Webhooks library verifies Fiscalapi deliveries with your secret as is, prefix included. You can also implement the verification with your language's standard library:

Verify the signature

using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

public static class WebhookVerifier
{
    private const long ToleranceSeconds = 5 * 60;

    // id, timestamp, and signature are the webhook-id, webhook-timestamp, and webhook-signature headers.
    // rawBody is the raw body, exactly as it arrived, not deserialized.
    // secrets is a list of whsec_ secrets: the current one and, during a rotation, the previous one too.
    public static JsonDocument Verify(string id, string timestamp, string signature, byte[] rawBody,
        IEnumerable<string> secrets, DateTimeOffset? now = null)
    {
        var nowSeconds = (now ?? DateTimeOffset.UtcNow).ToUnixTimeSeconds();
        if (string.IsNullOrEmpty(id) || !long.TryParse(timestamp, out var seconds) ||
            seconds < nowSeconds - ToleranceSeconds || seconds > nowSeconds + ToleranceSeconds)
            throw new CryptographicException("Timestamp fuera de la tolerancia");

        var content = Encoding.UTF8.GetBytes($"{id}.{timestamp}.").Concat(rawBody).ToArray();
        var expected = secrets
            .Select(secret => Convert.FromBase64String(secret.StartsWith("whsec_") ? secret[6..] : secret))
            .Select(key => HMACSHA256.HashData(key, content))
            .ToList();

        foreach (var entry in (signature ?? "").Split(' '))
        {
            var parts = entry.Split(',', 2);
            var received = new byte[HMACSHA256.HashSizeInBytes];
            if (parts.Length == 2 && parts[0] == "v1" &&
                Convert.TryFromBase64String(parts[1], received, out var written) && written == received.Length &&
                expected.Any(e => CryptographicOperations.FixedTimeEquals(e, received)))
                return JsonDocument.Parse(rawBody);
        }

        throw new CryptographicException("Firma inválida");
    }
}

Test your implementation with the official Standard Webhooks test vector. With the current time set to the vector's timestamp, the signature verifies; if you change one character of the body, the signature, or the secret, it fails:

FieldValue
Secretwhsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw
webhook-idmsg_p5jXN8AQM9LWM0D4loKWxJek
webhook-timestamp1614265330
Body{"test": 2432232314}
webhook-signaturev1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=

Respond to a delivery

Each delivery is a POST with Content-Type: application/json; charset=utf-8 and the User-Agent FiscalAPI-Webhooks/1. Fiscalapi waits up to 15 seconds in total for the response, including 5 to connect, and logs the status code and the first KB of the response.

Respond with a 2xx quickly and process the event later. A 3xx counts as a failure, and a 410 disables the webhook. Fiscalapi doesn't follow redirects: if you change the URL, update the webhook.

ResponseWhat Fiscalapi does
2xxTreats the delivery as received. The body of your response doesn't matter.
3xxFailure: retries on the schedule.
410Failure, and disables the webhook immediately: it receives no more events until you enable it.
429 or 503 with Retry-AfterFailure: retries at the next scheduled time or at the time you asked for, whichever is later, up to 24 hours.
Any other code, a timeout, or a connection or TLS errorFailure: retries on the schedule.

Store the event in a queue or a table and respond. Do the heavy work, such as fetching the resource or updating your system, outside the request: an endpoint that takes more than 15 seconds piles up retries of events it did receive.

Retries

If a delivery fails, Fiscalapi retries it on this schedule, counted from the previous failure. Each wait varies by up to 10% so that pending deliveries don't all arrive at once.

RetryWait
15 seconds; it arrives within the next minute.
25 minutes
330 minutes
42 hours
55 hours
610 hours
714 hours
820 hours
924 hours

That's 10 attempts over about 75 hours: an endpoint that's down for a weekend still receives its events. Each attempt carries a new webhook-timestamp and its own signature, with the same webhook-id and the same body. Resends and test deliveries are single attempts and don't follow this schedule.

Delivery is at least once: your endpoint can receive the same event more than once, for example if it responded with a 2xx but the response didn't reach Fiscalapi. Deduplicate by webhook-id: store the IDs you processed and, if a known one arrives, respond with a 2xx without repeating the work.

Order isn't guaranteed: a retry can arrive after a newer event for the same resource. For the authoritative status, send a GET to relatedObject.url with your base URL and your credentials; the API always returns the current status.

Disabling and notifications

Fiscalapi emails the tenant owner when something fails persistently, and it disables webhooks that don't respond:

SituationWhat happens
A delivery runs out of retries.The owner receives the email "Tu webhook no pudo entregar un evento" (Your webhook couldn't deliver an event), at most once every 24 hours per webhook. A resend or a test that fails doesn't trigger it.
A retry of a delivery fails, and the webhook has gone 5 days or more without responding with any 2xx.Fiscalapi disables it on that failure, and the owner receives the email "Deshabilitamos tu webhook" (We disabled your webhook). Without pending deliveries, the webhook isn't disabled on its own.
The endpoint responds with 410.Fiscalapi disables it immediately, and the owner receives the email "Deshabilitamos tu webhook" (We disabled your webhook).
You disable it.It stops receiving events. No email is sent.

The dashboard shows why a webhook is disabled: Por ti (By you), El webhook respondió 410 (The webhook responded with 410), or 5 días sin respuestas exitosas (5 days without successful responses). In the API, the reason comes in disabledReason.

Events generated while a webhook is disabled aren't delivered to it, not even after you enable it again. When you enable it, recover what you missed with Reconcile events.


Test your endpoint

Send a test event with Enviar evento de prueba (Send test event) in the dashboard or with Test a webhook. A webhook.test event arrives, signed like any other, only at the webhook you tested. Follow the result in the delivery log.

Test deliveries (the ones from this button, from Test a webhook, and from fiscalapi trigger) behave differently from the others:

RuleDetail
A single attemptIf they fail, they aren't retried and don't trigger the notification email.
Their 2xx doesn't count toward the webhook's healthA webhook that only responds to tests keeps accumulating its 5 days without successful responses. The 2xx of a resent test event doesn't count either.
Their failure does countA failure marks the webhook as failing if it wasn't already, and a 410 disables it.

Test locally

Fiscalapi doesn't deliver to localhost. To receive events on your computer without exposing it to the internet, use the fiscalapi CLI: it brings your tenant's events to your local server, triggers test events, and resends the ones you missed. Test webhooks locally explains how to install and use it.


Reconcile events

Fiscalapi keeps events and their deliveries for 30 days. After your endpoint goes down, or when you enable a webhook again, List events returns each envelope exactly as it was sent, newest first, with filters by type, resource, and date range.

Compare the id values with the ones you already processed. Process the missing ones from the list, or have them delivered again with Resend an event. A resend is a single attempt and arrives with the same webhook-id, so your deduplication recognizes it. An event older than 30 days can no longer be retrieved or resent.

Rotate the secret

Rotate the secret if it was exposed or as a routine security measure. In the dashboard, Rotar secreto (Rotate secret) asks for this confirmation: "Se generará un secreto nuevo. El actual seguirá firmando durante 24 horas para que actualices tu receptor sin perder eventos. ¿Continuar?" (A new secret will be generated. The current one keeps signing for 24 hours so you can update your endpoint without losing events. Continue?). With the API, call Rotate a secret.

For the next 24 hours, each delivery carries two signatures in webhook-signature, one with the new secret and one with the previous one. To switch secrets without losing events:

  1. Rotate the secret and save the new one.
  2. Configure your endpoint with both secrets; verification accepts the delivery if either signature matches.
  3. Once your endpoint has the new secret, remove the previous one. After 24 hours, the previous one stops signing.

If you rotate again within 24 hours, the previous secret keeps its original expiration and only the new one is replaced. That way, if an endpoint hasn't switched secrets yet, a double click doesn't leave it without a secret.

To see the current secret without rotating it, use Revelar secreto (Reveal secret) in the dashboard. It's only available in the dashboard, not with the API key, and every reveal is logged. While someone from support accesses your tenant on your behalf, no response includes the secret.

Endpoint best practices

PracticeWhy
Verify the signature of every delivery and reject the ones that fail.The signature is the only proof that the event comes from Fiscalapi.
Verify against the raw body.Serializing the JSON again changes the signed text.
Respond with a 2xx as soon as you store the event.A slow endpoint causes retries. If it goes 5 days or more without a 2xx, the webhook is disabled on the next failed retry.
Deduplicate by webhook-id.Delivery is at least once.
Fetch relatedObject.url before you act.Order isn't guaranteed, and the API has the current status.
Ignore the types you don't recognize and respond with a 2xx.New event types can be added without changing the envelope.
Store the secret the way you store your API key, outside your code.Anyone who has the secret can sign fake events.
Reconcile with the event list after an outage.What you missed is available for 30 days.

Rate limit

Test a webhook, Resend an event, and Trigger a test event, which fiscalapi trigger uses, have a rate limit per API key, shared with the creation of bulk download requests. The dashboard doesn't have this limit. Every response includes your usage in the RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset headers.

If you exceed it, the API responds with 429 and the Retry-After header, in seconds, and doesn't run the operation. Wait that long and repeat the request. The body of that response is in Dates and errors.

Related resources

Was this page helpful?