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
- In Holded, open Settings > Webhooks.
- Enter a publicly reachable HTTPS URL.
- Choose the events to subscribe to.
- Save, and store the webhook secret server-side, for example as
HOLDED_WEBHOOK_SECRET. Keep the whole value, includingwhsec_.
To manage webhooks from code, call the v2 API with a Bearer API key that has the developers:webhooks.write scope:
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_.
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:
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 verifies the
x-holded-webhook-signatureheader with your wholewhsec_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-signatureand stripsha256=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-eventheader. - 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 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 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
2xxquickly and process afterwards. - Check
GET /api/v2/webhooksperiodically and alert on any webhook whoseenabledisfalse. - 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 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.
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.
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 sits in front of your receiver, checks every Holded signature, and holds each event until your service has processed it.
Get started with Hookdeck for free and handle Holded webhooks reliably in minutes.