Skip to main content

Webhooks

Connect your applications to Holded via webhooks and receive real-time notifications when events occur, such as invoices, contacts, or stock changes, without needing to query the API constantly.

Written by Natalia López

Webhooks allow your applications and integrations to react in real time to what happens in Holded. Instead of querying the API every few minutes to detect changes, you tell Holded a URL and we send you a request the moment an event occurs: an invoice is created, a contact is updated, stock changes, etc.

This article explains how to create a webhook from Holded, what information is available afterward, and how to read and verify the requests we send.

This is a developer-oriented feature. To use webhooks you need an endpoint: a public https:// URL capable of receiving POST requests.


How it works

  1. You create an endpoint in Holded by specifying a URL and choosing which events you want to receive.

  2. When one of those events occurs, Holded sends a POST request to your URL with a JSON body summarizing what changed.

  3. Your endpoint responds with any 2xx status code to confirm receipt.

Each delivery is signed, so you can verify it really comes from Holded.


Creating a webhook

Webhooks are managed from Settings → Developers → Webhooks.

  1. Go to Settings → Webhooks and click + New webhook.

  2. Fill in the form:

◦ Description — a name to identify the webhook.

◦ Endpoint URL — the public https:// address that will receive the events.

  1. Under Events, select what you want to subscribe to. Events are grouped by area (Invoice, Sales Order, Stock, Contact…). Expand each area and check the actions you're interested in: Create, Update, Delete, or Approve. You can use the search box to find them quickly.

  2. On the right, under Selected events, you'll see a summary of everything you've checked, grouped by area.

  3. Click Create.

The signing secret

When you create the webhook, Holded shows the signing secret (prefixed with whsec_) only once. Copy it and store it somewhere safe at that moment: you'll need it to verify that requests really come from Holded.

Later you can find it in the webhook's settings, but it will appear partially masked (for example, whsec_••••f776), so it's best to save the full value now. Click Done to finish; the webhook will appear in the list with the status Active.

You can create more than one endpoint (for example, one for billing events and another for inventory) and edit or delete them whenever you want.

☝️ The webhook is configured per Holded account, not at the organization level. If you have several accounts, set up a webhook in each one. They can all point to the same URL: each delivery includes the x-holded-webhook-account-id header so your system can identify which account it came from.


Managing a webhook

From the list, click a webhook to open its details. Using the toggle next to the name, you can enable or disable it at any time. The detail view has three tabs: Summary, Deliveries, and Settings.

Summary

Shows the endpoint's health over the selected period (24h, 7d, or 30d):

  • Success rate — percentage of successful deliveries out of the total.

  • P50 latency — median response time of your endpoint.

  • Consecutive failures — failed deliveries in a row (useful for detecting a downed endpoint).

  • Daily peak — maximum number of deliveries in a day.

  • Deliveries per hour — volume chart over time.

  • By status — count of Successful, Warning, and Error deliveries.

Deliveries

This is the webhook's delivery log. You can filter by status (All, Successful, Warnings, Errors), narrow the period (24h/7d/30d), and search by event, ID, or payload. The table shows, for each delivery, its Status, the Event · ID, the Time, and the Latency.

This tab is also the most reliable way to see the exact content your endpoint receives: open any delivery to inspect its actual payload.

Settings

Gathers the webhook's data and settings:

  • Identity — ID, API version, retries, creation and update dates, and source IP.

  • Subscribed events — summary of the areas and actions it's subscribed to. Click Modify to change the selection.

  • Signing secret — the signing secret, masked, with a Copy button.

  • Delete webhook — deletes the endpoint. This action cannot be undone.


The webhook request

Each event is delivered as an HTTP POST request with a JSON body. Holded includes these headers in every request:

Header

Type

Description

x-holded-webhook-event

string

Event name, e.g. invoice.create

x-holded-webhook-date

string (ISO 8601)

Time of sending, e.g. 2026-06-18T11:10:35Z

x-holded-webhook-signature

string

HMAC-SHA256 signature prefixed with sha256= (see below)

x-holded-webhook-id

string

Unique event identifier; use it as an idempotency key

x-holded-webhook-account-id

string

Identifier of the Holded account originating the event

x-holded-webhook-version

string

Version of the webhook system (e.g. v1)

Your endpoint must return any 2xx code to confirm delivery. A response other than 2xx (or a timeout) is considered a failed delivery, and Holded retries the delivery up to 5 times.

Respond quickly. Confirm first (return 2xx) and process afterward asynchronously. Heavy work inside the request can cause timeouts and unnecessary retries.

Since a delivery can be retried, design your handler to be idempotent: receiving the same event twice should not duplicate effects. The x-holded-webhook-id header is a reliable deduplication key.


Verifying the signature

Anyone who knows your endpoint's URL could send it requests, so it's a good idea to verify that each one really comes from Holded before processing it.

Holded signs each request with the signing secret (whsec_…) generated when the endpoint was created. The x-holded-webhook-signature header contains the hexadecimal HMAC-SHA256 digest —preceded by the prefix sha256=— of the string:

<x-holded-webhook-date>.<raw request body>

To verify a request:

  1. Read the raw body.

  2. Compute HMAC-SHA256(string, your_secret) in hexadecimal.

  3. Strip the sha256= prefix from x-holded-webhook-signature and compare both values using a constant-time comparison.


The payload: a summary, not full data

The webhook body is a summarized notification of the affected record, not the full object. For example, an invoice payload looks like this:

{

"id": "6a33d22b2fa645a14c024bd0",

"type": "invoice",

"documentNumber": null,

"date": "2026-06-18T00:00:00+02:00",

"dueDate": null,

"contactId": "68b89460c77cebb2cb0fb799",

"contactName": "Empresa Ejemplo S.L.",

"total": "121",

"subtotal": "100",

"isDraft": true

}

For the full data —document lines, tags, currency, payment status, accounting account, custom fields, etc.— you need to make a GET call to the corresponding resource using the id from the payload. In other words, the webhook tells you what changed and when; the API gives you the detail.

You can check the full structure of each payload, field by field, in the developer reference: www.holded.com/developers/webhooks


Event catalog

Each event's name follows the <area>.<action> pattern and travels in the x-holded-webhook-event header. In the interface, the area appears with its Spanish name and the action as Create / Update / Delete / Approve; the technical event name is in the right-hand column.

  • .create (Create) — a new record has been created.

  • .update (Update) — an existing record has changed.

  • .delete (Delete) — a record has been deleted.

  • .approve (Approve) — the document has been approved (only for types with an approval status).

Sales documents

Area

Events

Invoice

invoice.create · invoice.update · invoice.delete · invoice.approve

Sales receipt

salesreceipt.create · salesreceipt.update · salesreceipt.delete · salesreceipt.approve

Credit note

creditnote.create · creditnote.update · creditnote.delete · creditnote.approve

Estimate

estimate.create · estimate.update · estimate.delete

Proforma

proform.create · proform.update · proform.delete

Sales order

salesorder.create · salesorder.update · salesorder.delete

Waybill

waybill.create · waybill.update · waybill.delete

Purchase documents

Area

Events

Expense

purchase.create · purchase.update · purchase.delete · purchase.approve

Purchase order

purchaseorder.create · purchaseorder.update · purchaseorder.delete

Purchase refund

purchaserefund.create · purchaserefund.update · purchaserefund.delete · purchaserefund.approve

Receipt note

receiptnote.create · receiptnote.update · receiptnote.delete · receiptnote.approve

Receipt

receipt.create · receipt.update · receipt.delete

Inventory and catalog

Area

Events

Stock

stock.update

Product

product.create · product.update · product.delete

Contacts

Area

Events

Contact

contact.create · contact.update · contact.delete


Coverage and limitations

It's worth knowing what the webhook system covers and what it doesn't, in order to design your integration well.

Full coverage. All document types (invoices, expenses, estimates, proformas, etc.) emit create, update, delete, and approve as applicable, and contacts emit create, update, and delete. These resources can be kept in sync via webhook after an initial load.

Partial coverage.

  • Product — product.create and product.update exist, but product.delete does not exist. Deleted products are not detected via webhook; for that you need periodic polling that compares the catalog.

  • Stock — only stock.update exists, which reflects movements, not the initial state. Before you start relying on the webhook you need an initial load of the starting stock.

No coverage. These resources do not emit webhooks; if you need to sync them, you need periodic polling: warehouses, services, projects, tasks, project time tracking, payments, chart of accounts, general ledger, and recurring invoices.

Payments. There is no dedicated event for payments. Recording a payment triggers an invoice.update or purchase.update, and the payment status doesn't come in the payload: do a GET on the document to find out.


Troubleshooting

  • Not receiving events? Check that the endpoint URL is public, uses https://, returns 2xx quickly, and that the events are selected for that webhook. Check the Deliveries tab to see the detail of each attempt.

  • Signature verification failing? Make sure you're reading the x-holded-webhook-* headers, stripping the sha256= prefix before comparing, and signing the raw body (not a re-serialized JSON) by joining the timestamp and body with a period.

  • Duplicate events? This is expected on retries: make your handler idempotent using x-holded-webhook-id.

Did this answer your question?