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-signature header?
  • Why does my Holded signature verification fail with a whsec_ secret?
  • How do I handle invoice.create, contact.create or payment.create events?
  • How do I deduplicate Holded deliveries?

Verification (core)

Every delivery carries these headers:

HeaderMeaning
x-holded-webhook-signaturesha256=<64 hex chars>, HMAC-SHA256 of the raw body
x-holded-webhook-eventEvent name, e.g. invoice.create
x-holded-webhook-idUnique event id, use it as the idempotency key
x-holded-webhook-dateISO 8601 send time. Not signed, don't use it for replay checks
x-holded-webhook-account-idHolded account that triggered the event
x-holded-webhook-versionWebhook 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):

EventTriggered When
invoice.create / .update / .delete / .approveSales 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 / .deleteContact changes
payment.create / .update / .deletePayments recorded against documents
product.* / service.* / warehouse.*Catalog and warehouse changes
stock.updateStock level changed

For the full list, see references/overview.md and the Holded webhooks documentation.

Delivery and Idempotency

  • Return any 2xx to acknowledge. Otherwise Holded retries with exponential backoff.
  • Deduplicate on x-holded-webhook-id.
  • Return 2xx for 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


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 9, 2026

View on GitHub →