Agent skill
Holded Webhooks Skill
Receive and verify Holded webhooks. Use when setting up Holded webhook handlers, debugging x-holded-webhook-signature verification (sha256=<hex>, HMAC-SHA256 of the raw body keyed with the whole whsec_ secret, not Standard Webhooks), or handling events like invoice.create, invoice.approve, contact.create, payment.create and stock.update.
Install this skill
npx skills add hookdeck/webhook-skills --skill holded-webhooks
When to Use This Skill
- How do I receive Holded webhooks?
- How do I verify the
x-holded-webhook-signatureheader? - Why does my Holded signature verification fail with a
whsec_secret? - How do I handle
invoice.create,contact.createorpayment.createevents? - How do I deduplicate Holded deliveries?
Verification (core)
Every delivery carries these headers:
| Header | Meaning |
|---|---|
x-holded-webhook-signature | sha256=<64 hex chars>, HMAC-SHA256 of the raw body |
x-holded-webhook-event | Event name, e.g. invoice.create |
x-holded-webhook-id | Unique event id, use it as the idempotency key |
x-holded-webhook-date | ISO 8601 send time. Not signed, don't use it for replay checks |
x-holded-webhook-account-id | Holded account that triggered the event |
x-holded-webhook-version | Webhook system version, e.g. v1 |
The secret looks like whsec_... but this is not Standard Webhooks: don't use the standardwebhooks or svix libraries, and don't strip or base64-decode the prefix. The HMAC key is the whole secret string, whsec_ included, as UTF-8. Require and strip the sha256= prefix, and answer 401 on failure.
Node (from examples/express/):
function verifyHoldedSignature(rawBody, header, secret) {
if (!header || !header.startsWith('sha256=')) return { valid: false };
const received = header.slice('sha256='.length);
// Buffer.from(x, 'hex') silently truncates at invalid characters: check shape first.
if (!/^[0-9a-f]{64}$/i.test(received)) return { valid: false };
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest();
return { valid: crypto.timingSafeEqual(Buffer.from(received, 'hex'), expected) };
}
Python (from examples/fastapi/):
def verify_holded_signature(raw_body: bytes, header: str | None, secret: str) -> bool:
if not header 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 non-ASCII str (a 500, not a 401).
return hmac.compare_digest(expected.encode(), received.lower().encode())
Read the raw body (express.raw({ type: '*/*' }) in Express, request.text() in Next.js, await request.body() in FastAPI) so any content type verifies.
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
Common Event Types
The event name is in the x-holded-webhook-event header. Events are grouped by document type, each with create, update and delete (plus approve where noted):
| Event | Triggered When |
|---|---|
invoice.create / .update / .delete / .approve | Sales invoice lifecycle |
creditnote.* / salesreceipt.* / purchase.* / purchaserefund.* / receiptnote.* | Same four events (including .approve) |
proform.* / estimate.* / salesorder.* / waybill.* / receipt.* / purchaseorder.* | create, update, delete (proforma invoices use the prefix proform) |
contact.create / .update / .delete | Contact changes |
payment.create / .update / .delete | Payments recorded against documents |
product.* / service.* / warehouse.* | Catalog and warehouse changes |
stock.update | Stock level changed |
For the full list, see references/overview.md and the Holded webhooks documentation.
Delivery and Idempotency
- Return any
2xxto acknowledge. Otherwise Holded retries with exponential backoff. - Deduplicate on
x-holded-webhook-id. - Return
2xxfor event types you don't handle.
Environment Variables
HOLDED_WEBHOOK_SECRET=whsec_your_holded_webhook_secret # Whole string, used as the HMAC key
Setup
Configure webhooks in Holded under Settings > Webhooks with a public HTTPS URL and the events to subscribe to. See references/setup.md.
Hookdeck: paste the whole secret, including the whsec_ prefix, into the source's webhook secret. Holded uses it as a plain HMAC key.
Local Development
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 holded --path /webhooks/holded
Use the Hookdeck URL as the endpoint in Holded's webhook settings.
Reference Materials
- references/overview.md - Headers, event catalogue, delivery behaviour
- references/setup.md - Dashboard configuration, secret, Hookdeck
- references/verification.md - Signature details and gotchas