Skip to content

Overview

A webhook is Faturei Hoje telling your system when something happens in the company: a client created, a sale paid, a service order completed. Instead of you asking the API every so often, the API sends a POST with the event to an address of yours, the endpoint.

  1. Someone writes something: through the dashboard, the API, WhatsApp, an automation or an automatic routine.
  2. In that same write, the event is recorded with the object exactly as the resource’s GET would return it at that moment, under the same field rules.
  3. The event becomes one delivery for each enabled endpoint subscribed to it.
  4. The delivery is signed and sent. If it fails, it is retried on a schedule of about 3 days. See Deliveries and retries.

The event is only recorded when, at that moment, the company has at least one enabled endpoint subscribed to that event type. What happens while no endpoint is listening does not become an event, not even later.

Through the API, with POST /public/v1/webhook_endpoints:

Terminal window
curl -X POST "https://api.fatureihoje.com/public/v1/webhook_endpoints" \
-H "Authorization: Bearer $FH_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 0f5d3c1e-7a2b-4e8f-9c6d-1b2a3c4d5e6f" \
-d '{
"url": "https://erp.yourcompany.com/webhooks/faturei-hoje",
"description": "ERP",
"events": ["sale.created", "sale.paid", "client.created"]
}'

The response brings the endpoint and the secret field, which starts with whsec_. Through the API, the secret is only shown here and on rotation: store it on your server, it is what you use to verify the signature. Repeating the same call with the same Idempotency-Key returns the endpoint without the secret. In the dashboard, the owner and admins can reveal the secret again by confirming their password.

  • events is a list of names from the Event catalog, or ["*"] for all of them, including those added to the catalog later. * goes alone: together with another name, the response is 422.
  • Managing webhooks belongs to the owner and admins, in the dashboard (under Settings → API → Webhooks) or with an API key of a member with that role. The read routes require the role too, because the endpoint list says where the company’s data goes. In the dashboard, changing the URL or the events asks for the password.
  • Receiving an event means reading its object. So, to subscribe, the key needs <module>:read for each module of the chosen events (with *, all of them). Without it, the response is 403.
  • Each endpoint has its own secret. Two endpoints never share a secret.
  • The owner and admins get an email for every endpoint created.

To test the connection, POST /public/v1/webhook_endpoints/{id}/ping sends a real ping event to that endpoint only, through the same path as any event (signature, retries, delivery log). It is not a test environment: it only checks that the address receives and verifies. A disabled endpoint answers 409 conflict.

The full routes, field by field, are in the Reference.

The URL is checked on registration, and the same check runs again on every delivery attempt, because the domain owner can change the DNS after registration.

  • https:// only. An http:// address is refused.
  • Any port from 1 to 65535.
  • No user and password in the URL (https://user:password@...): it is refused, because it would be stored and would show up when reading the endpoint.
  • The name must resolve to a public internet address. localhost, names without a dot, names ending in .local, .internal, .lan and the like, and private, reserved or cloud metadata IPs are refused. All A and AAAA records of the name are checked: a single internal one is enough to refuse.
  • The TLS certificate must be valid. A self-signed or expired certificate makes the delivery fail.
  • Redirects are not followed. A 3xx counts as a failure.

On registration, a refused URL answers 422 with param: "url". On delivery, the attempt fails and the reason shows up in the delivery log.

Each delivery is a POST with these headers, following the Standard Webhooks spec:

HeaderValue
webhook-idEvent id, evt_ followed by 32 hexadecimal characters. The same id as in the body
webhook-timestampTime of this attempt, in Unix seconds
webhook-signatureThe signature, v1, followed by the HMAC in base64. See Verify the signature
content-typeapplication/json
user-agentFatureiHoje-Webhooks/1.0

And this body:

{
"id": "evt_9b2f4c7e1a3d4f6b8c0e2a4d6f8b1c3e",
"type": "client.updated",
"api_version": "v1",
"timestamp": "2026-09-16T14:30:00.000Z",
"organization_id": "5c1e8f2a-3b4d-4c6e-8f0a-1b2c3d4e5f60",
"data": {
"object": { "object": "client", "id": "0d9a7c5e-3f1b-4d2a-8c6e-4a2b0c8e6f1d", "...": "..." },
"changed_fields": ["email", "phone"]
}
}
FieldWhat it is
idEvent id. The same on every attempt and on a manual resend
typeEvent name, from the Event catalog
api_versionVersion of the object contract. Today, always v1
timestampWhen the event happened, in ISO 8601 UTC. It does not change between attempts
organization_idThe event’s company
data.objectThe object, the same the resource’s GET would return at that moment
data.changed_fieldsOnly on .updated events: which fields changed
data.object_truncatedOnly present when the object came summarized, and then it is true

Note the difference between the two times: webhook-timestamp is the send time and changes on every attempt (it is what protects against malicious replay); timestamp, in the body, is when the fact happened, and it is what you order by.

The ping has the same format, with type: "ping" and an object of its own:

{
"id": "evt_4e6a8c0b2d4f4a6c8e0b2d4f6a8c0e2b",
"type": "ping",
"api_version": "v1",
"timestamp": "2026-09-16T14:30:00.000Z",
"organization_id": "5c1e8f2a-3b4d-4c6e-8f0a-1b2c3d4e5f60",
"data": {
"object": { "object": "ping", "webhook_endpoint_id": "7f3a1c5e-9b2d-4f6a-8c0e-2b4d6f8a0c1e" }
}
}

On .updated events, changed_fields lists the object fields that changed in that write, in alphabetical order. updated_at never appears in the list, because it changes on every write and says nothing about what changed.

One write can produce more than one event. If a service order’s status changed together with its description, both service_order.status_changed and service_order.updated are sent, and the second one’s changed_fields has both fields. The rules for each area are in the Event catalog.

A write that changes nothing in the object does not produce .updated: saving a record without changing any field sends no event.

The delivery body is capped at 256 KB, counted in bytes. When the object does not fit, data.object comes with only object and id, and data.object_truncated is true:

{
"data": {
"object": { "object": "sale", "id": "3c5e7a9b-1d3f-4b5d-8f1a-3c5e7a9b1d3f" },
"object_truncated": true
}
}

With object_truncated, fetch the object through the API (GET /public/v1/sales/{id}, in the example). It comes as it is now, which may differ from how it was at the moment of the event.

Some write paths do not build the full object. From them, the event is always summarized, in the same format as the cap above: data.object = { object, id } and data.object_truncated: true. They are:

  • what the WhatsApp assistant writes;
  • what automations write;
  • what comes from Open Finance (bank import and reconciliation);
  • the automatic routines: the one that marks entries and sales as overdue, and the one that generates the next months of open-ended recurrences;
  • and, rarely, any other write where the object could not be built at that moment.

The event type is the right one (task.created, financial_transaction.updated…), but the object does not come. Fetch it through the API. In a summarized .updated, changed_fields is empty, because without the object there is no way to know which fields changed.

Always handle object_truncated: true the same way, whether it comes from the size cap or from one of these paths: fetch the object through the API.

Some changes to an object happen without an event of their own. They are a consequence of another write, and the event of that other write is what you receive.

  • A link cleared along with its parent. When a record is deleted, whatever pointed to it loses the link (the field becomes null) without .updated. For example: the tasks and appointments of a client, a service order, a financial entry or a member that was deleted, and the service order of a deleted appointment. You receive the parent’s .deleted.
  • Deleting a product. Sale items lose their product_id and the product’s stock movements are deleted, with no sale.updated and no stock.changed. You receive product.deleted.
  • Renaming a category does not produce product.updated or service.updated on its items.
  • Receipt number. The receipt_number of a sale payment (payments[].receipt_number) goes from null to a number (R-0001, for example) when someone generates, in the dashboard and for the first time, the receipt of that payment’s financial entry. This happens without sale.updated.
  • Invoices. The sale’s invoice_locked and the payments’ has_active_invoice change when an invoice is issued or canceled, without sale.updated.
  • A task’s is_overdue changing on its own, as time passes, does not produce task.updated.
  • Quote expiration. An expired quote only changes status when someone opens the quote list. The expiration quote.status_changed is sent at that moment, not on the expiry date.

When the data must be exact, read the resource through the API again instead of relying only on events. See Best practices.

Each endpoint stores who subscribed it: the member, and the API key when the subscription came through the API. The subscriber is whoever created the endpoint or, later, whoever changed its URL or events, or re-enabled it.

Before each send, Faturei Hoje checks whether the subscriber can still read that event’s module: the key is still active and has <module>:read, and the member is still active and has access to the module, under the same rules as the dashboard. If not:

  • the delivery of that event is closed without being sent, with status failed and error subscriber_access_lost in the log;
  • this does not use up an attempt and does not count as an endpoint failure; the endpoint stays enabled;
  • events from other modules, which the subscriber still reads, keep arriving;
  • the owner and admins get one email about it, and the notice is only sent again after a delivery succeeds and access is lost once more.

To receive again, give the subscriber their access back, or edit the endpoint with a member (or key) that has the access: whoever edits the URL or the events becomes the subscriber.

Sale events (sale.*) never carry account_id or finance_transactions, even if the subscriber reads finance: subscribing to sales only requires reading sales. Financial entries have their own events, financial_transaction.*, which require reading finance.

A company that moves to a plan without the API stops receiving deliveries, ping included: they are closed without being sent, with the error plan_without_api.

  • Rotation with an overlap (any grace period above zero, such as the dashboard’s 1 hour and 24 hours options): endpoints subscribed with the old key move to the new key in the same action, and nothing stops arriving.
  • “Stop now” rotation, the move of someone who suspects the key leaked: endpoints subscribed with the old key are paused, because whoever got the key may have registered an endpoint to receive your data. They show up with status: "disabled" and disabled_reason: "emergency_key_rotation", and the owner and admins get an email with the list (only the domain of each URL). Review the list and re-enable what is yours.
  • Key found by GitHub in a public repository, once the enrollment in GitHub’s secret scanning program is active: the key is revoked automatically and the endpoints subscribed with it are paused the same way, with the same disabled_reason: "emergency_key_rotation", and the owner and admins get an email. The enrollment is not active yet.
  • Revoked or expired key (other than the case above): endpoints subscribed with it stop receiving, under the access rule above.

See Rotation.

How many endpoints the company can have registered, enabled or not:

PlanWebhook endpoints
Free0
Start3
Pleno10
Supra25

When sending:

  • up to 5 simultaneous deliveries per endpoint;
  • 1 delivery at a time for an endpoint that is failing, until a delivery to it succeeds;
  • up to 10 simultaneous deliveries per company, across all endpoints. A slow endpoint does not hold up other companies’ queues;
  • an endpoint that only fails for 3 days in a row is disabled, and the owner and admins get an email. See Automatic disabling.

Data sent becomes the responsibility of whoever set it up

Section titled “Data sent becomes the responsibility of whoever set it up”

Each delivery takes company data out of Faturei Hoje, to the system the endpoint points to. From the moment the data reaches that system, it becomes the responsibility of the company that set up the integration, including under the LGPD (Brazil’s data protection law): who stores it, for how long, who accesses it and how it is disposed of.

On our side, events and the delivery log are kept for 30 days and then deleted. Sending runs on our own infrastructure, with no third-party service in between.