Gareth Wilson Gareth Wilson

Guide to Holded Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationThe dashboard (Settings > Webhooks) or the v2 webhooks API
Subscription modelA list of events per webhook
Event nameThe x-holded-webhook-event header, for example invoice.create
Signature headerx-holded-webhook-signature: sha256=<hex>
SchemeHMAC-SHA256, hex digest
Signed contentThe raw request body only
Secretwhsec_..., used whole as the key. Not Standard Webhooks
Idempotency keyThe x-holded-webhook-id header
Timestampx-holded-webhook-date (ISO 8601). Not signed
Versionx-holded-webhook-version, currently v1
AcknowledgementAny 2xx response
RetriesExponential backoff. Attempts and intervals are not published
Source IPsNot published

Common events

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

GroupEvents
Invoicesinvoice.create, invoice.update, invoice.delete, invoice.approve
Credit notescreditnote.create, creditnote.update, creditnote.delete, creditnote.approve
Sales receiptssalesreceipt.create, salesreceipt.update, salesreceipt.delete, salesreceipt.approve
Other sales documentsproform.* (proforma invoices), estimate.*, salesorder.*, waybill.*, each with create, update and delete
Purchasespurchase.* and purchaserefund.* (with approve), receipt.*, purchaseorder.*, and receiptnote.* (with approve)
Paymentspayment.create, payment.update, payment.delete
Contactscontact.create, contact.update, contact.delete
Catalogueproduct.*, service.* and warehouse.*, each with create, update and delete
Stockstock.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:

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-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 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 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 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.