# Guide to Holded Webhooks: Features and Best Practices

Holded webhooks tell your systems what is changing in a Holded account: an invoice is created or approved, a payment is recorded, a contact is edited or stock levels move. If your business runs its invoicing, purchasing and inventory on Holded, the Spanish business-management and accounting platform, webhooks are how those changes reach your own services, such as a data warehouse, a CRM sync or a fulfilment system, without polling the API.

This guide covers how Holded webhooks are configured and signed, the events you can subscribe to, what arrives in the headers and the body, and the practices that keep a Holded receiver reliable.

## What are Holded webhooks?

A Holded webhook is an HTTPS URL subscribed to a list of events. When one of those events happens in the account, Holded sends an HTTP `POST` to the URL. The body is the resource that changed, such as the invoice or the contact, and the metadata about the delivery travels in headers: the event name, a unique event ID, the account ID, the send time and the signature.

The signature header, `x-holded-webhook-signature`, holds `sha256=` followed by the hex HMAC-SHA256 of the raw request body. The key is the webhook secret, which starts with `whsec_`. That prefix is misleading. Holded does not use Standard Webhooks: the whole secret, `whsec_` included, is the HMAC key, and there are no `webhook-id` or `webhook-timestamp` headers.

## Holded webhook features

| Feature | Details |
| --- | --- |
| Configuration | The dashboard (**Settings > Webhooks**) or the v2 webhooks API |
| Subscription model | A list of events per webhook |
| Event name | The `x-holded-webhook-event` header, for example `invoice.create` |
| Signature header | `x-holded-webhook-signature: sha256=<hex>` |
| Scheme | HMAC-SHA256, hex digest |
| Signed content | The raw request body only |
| Secret | `whsec_...`, used whole as the key. Not Standard Webhooks |
| Idempotency key | The `x-holded-webhook-id` header |
| Timestamp | `x-holded-webhook-date` (ISO 8601). Not signed |
| Version | `x-holded-webhook-version`, currently `v1` |
| Acknowledgement | Any `2xx` response |
| Retries | Exponential backoff. Attempts and intervals are not published |
| Source IPs | Not published |

## Common events

Holded documents 58 events in 18 groups. Most groups have `create`, `update` and `delete`, and the approvable documents add `approve`.

| Group | Events |
| --- | --- |
| Invoices | `invoice.create`, `invoice.update`, `invoice.delete`, `invoice.approve` |
| Credit notes | `creditnote.create`, `creditnote.update`, `creditnote.delete`, `creditnote.approve` |
| Sales receipts | `salesreceipt.create`, `salesreceipt.update`, `salesreceipt.delete`, `salesreceipt.approve` |
| Other sales documents | `proform.*` (proforma invoices), `estimate.*`, `salesorder.*`, `waybill.*`, each with `create`, `update` and `delete` |
| Purchases | `purchase.*` and `purchaserefund.*` (with `approve`), `receipt.*`, `purchaseorder.*`, and `receiptnote.*` (with `approve`) |
| Payments | `payment.create`, `payment.update`, `payment.delete` |
| Contacts | `contact.create`, `contact.update`, `contact.delete` |
| Catalogue | `product.*`, `service.*` and `warehouse.*`, each with `create`, `update` and `delete` |
| Stock | `stock.update` |

Proforma invoices use the prefix `proform`, not `proforma`. `GET https://api.holded.com/api/v2/webhooks/events` returns the supported event types grouped by domain, so check it rather than relying on a copied list.

There is no envelope. The body is the resource, and for documents that means an object with fields such as `id`, `type`, `documentNumber`, `date`, `dueDate`, `contactId`, `contactName`, `total`, `status`, `currency`, `lines` and `payments`. The `type` field is the document type (invoice, sales order, estimate and so on), not the event, so read the event from the `x-holded-webhook-event` header. Holded's per-event example payloads contain placeholder values like `"string"` and `0`, so capture a real delivery before you write field-level parsing.

## Setting up Holded webhooks

1. In Holded, open **Settings > Webhooks**.
2. Enter a publicly reachable HTTPS URL.
3. Choose the events to subscribe to.
4. Save, and store the webhook secret server-side, for example as `HOLDED_WEBHOOK_SECRET`. Keep the whole value, including `whsec_`.

To manage webhooks from code, call the v2 API with a Bearer API key that has the `developers:webhooks.write` scope:

```bash
curl -X POST "https://api.holded.com/api/v2/webhooks" \
  -H "Authorization: Bearer $HOLDED_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/holded",
    "events": ["invoice.create", "invoice.approve", "payment.create"],
    "description": "Accounting sync"
  }'

```

The `201` response includes the webhook's `id`, `enabled` flag and `secret`. The same API lists, updates, enables, disables and deletes webhooks.

If you receive Holded webhooks through Hookdeck, create a Holded source and paste the whole secret into it, including the `whsec_` prefix. Holded uses the secret as a plain HMAC key, and Hookdeck uses it exactly as entered.

## Securing Holded webhooks

Read `x-holded-webhook-signature`, require and strip the `sha256=` prefix, compute an HMAC-SHA256 of the raw body with the whole secret as a UTF-8 key, and compare the hex digests in constant time. Do not use a Standard Webhooks or Svix library, and do not strip or base64-decode `whsec_`.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyHoldedSignature(rawBody, header, secret) {
  if (!header || !secret || !header.startsWith('sha256=')) return false;
  const received_hex = header.slice('sha256='.length);
  // Buffer.from(x, 'hex') silently stops at invalid characters, so check the shape first.
  if (!/^[0-9a-f]{64}$/i.test(received_hex)) return false;

  // The key is the whole secret string, whsec_ included.
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest();
  const received = Buffer.from(received_hex, 'hex');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

const app = express();

app.post('/webhooks/holded', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.HOLDED_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyHoldedSignature(req.body, req.get('x-holded-webhook-signature'), secret)) {
    return res.sendStatus(401);
  }

  const event_type = req.get('x-holded-webhook-event');
  const event_id = req.get('x-holded-webhook-id');
  const resource = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on event_id, route on event_type, then process asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac
import re

def verify_holded_signature(raw_body: bytes, header, secret: str) -> bool:
    if not header or not secret or not header.startswith("sha256="):
        return False
    received = header[len("sha256="):]
    if not re.fullmatch(r"[0-9a-fA-F]{64}", received):
        return False
    expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    # Compare bytes: compare_digest raises TypeError on a str with non-ASCII characters.
    return hmac.compare_digest(expected.encode("utf-8"), received.lower().encode("utf-8"))

# In FastAPI: raw_body = await request.body()
# verify_holded_signature(raw_body, request.headers.get("x-holded-webhook-signature"),
#                         os.environ["HOLDED_WEBHOOK_SECRET"])

```

Holded's own Node sample mounts `express.raw({ type: 'application/json' })` and strips the prefix with `.replace(/^sha256=/, '')`. Mounting `*/*` keeps the body raw whatever content type arrives.

> **Make Holded webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `x-holded-webhook-signature` header with your whole `whsec_` secret, stores every invoice, payment and stock event, and delivers it to your services with retries you control.

## Holded webhook limitations and pain points

### A `whsec_` secret that isn't Standard Webhooks

**The Problem:** Every delivery fails verification, even though the secret is correct.

**Why It Happens:** The `whsec_` prefix suggests Standard Webhooks, whose libraries strip the prefix, base64-decode the rest and expect `webhook-id`, `webhook-timestamp` and `webhook-signature` headers. Holded signs the body alone with the whole string as the key, so a Standard Webhooks verifier never matches, and neither does a hand-written one that decodes the secret.

**Workarounds:**

* Use a plain HMAC-SHA256 keyed with the full secret, as above.
* Read `x-holded-webhook-signature` and strip `sha256=` before comparing.

**How Hookdeck Can Help:** Hookdeck's Holded source type uses the secret exactly as you paste it and parses the `sha256=` prefix for you. Requests that fail the check are marked as failed verification and are not delivered to your destinations.

### The event name isn't in the body

**The Problem:** A handler that reads the event type from the JSON body routes everything wrongly, or not at all.

**Why It Happens:** The body is the resource, with no envelope. Document bodies do have a `type` field, but it holds the document type, such as `invoice` or `salesorder`, so `invoice.create`, `invoice.update` and `invoice.approve` all carry the same `type`. The event name only travels in `x-holded-webhook-event`.

**Workarounds:**

* Route on the `x-holded-webhook-event` header.
* If you queue events for later processing, store the header alongside the body, or wrap them together before enqueueing.

**How Hookdeck Can Help:** Hookdeck keeps the headers with every event, and [filters](/docs/filters) can match on `x-holded-webhook-event` to send different events to different destinations.

### No signed timestamp

**The Problem:** A captured request still verifies if it is sent again later.

**Why It Happens:** Only the body is signed. `x-holded-webhook-date` is sent, but it is not part of the signature, so anyone replaying a request can change it.

**Workarounds:**

* Deduplicate on `x-holded-webhook-id`, which Holded documents as the idempotency key.
* For actions with financial consequences, re-read the document from the Holded API before acting.
* Keep the endpoint on HTTPS.

**How Hookdeck Can Help:** Hookdeck's [deduplication](/docs/deduplication) can drop repeated deliveries with the same `x-holded-webhook-id` before they reach your service.

### An undocumented retry schedule

**The Problem:** You can't tell how long an outage your receiver can survive before events are lost, or when a webhook might be disabled.

**Why It Happens:** Holded retries non-`2xx` responses "with exponential backoff" but does not publish the number of attempts, the intervals or a timeout. The webhook object has an `enabled` flag and a `disable` record with a date, a reason and who disabled it, and the API can re-enable a webhook, but Holded does not document when it disables one.

**Workarounds:**

* Return `2xx` quickly and process afterwards.
* Check `GET /api/v2/webhooks` periodically and alert on any webhook whose `enabled` is `false`.
* Reconcile against the Holded API after an outage.

**How Hookdeck Can Help:** Hookdeck accepts and stores each delivery as it arrives, then delivers it to your service with [automatic retries](/docs/retries) and manual replay, so your own downtime does not depend on Holded's retry schedule.

## Best practices

### Verify against the raw body

The signature covers the exact bytes Holded sent. In Express, mount `express.raw()` on the webhook route and parse only after verification. In a Next.js route handler, use `await request.text()`. In FastAPI, read `await request.body()`.

### Deduplicate on `x-holded-webhook-id`

Retries mean the same event can arrive more than once. Store each `x-holded-webhook-id` before processing and skip repeats. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Subscribe only to the events you handle

A busy account produces a lot of `update` events. Subscribe to the groups and actions you use, and answer `2xx` to anything unexpected so it doesn't count as a failure.

### Treat fields as nullable

Holded's field tables mark almost every document field as nullable, from `documentNumber` to `lines`. Use optional access and defaults throughout, and expect a `delete` event to carry less than a `create`.

### Acknowledge fast and process asynchronously

Verify, persist the event with its headers, return `200`, and do the work afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Holded signs each delivery with `x-holded-webhook-signature`: `sha256=` and a hex HMAC-SHA256 of the raw body, keyed with the whole `whsec_` secret. Verify with a plain HMAC rather than a Standard Webhooks library, route on the `x-holded-webhook-event` header rather than the body's `type`, deduplicate on `x-holded-webhook-id`, and acknowledge quickly because the retry schedule is not published.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks every Holded signature, and holds each event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Holded webhooks reliably in minutes.