# 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:

| 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/](https://github.com/hookdeck/webhook-skills/tree/main/skills/holded-webhooks/examples/express/)):

```javascript
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/](https://github.com/hookdeck/webhook-skills/tree/main/skills/holded-webhooks/examples/fastapi/)):

```python
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/](https://github.com/hookdeck/webhook-skills/tree/main/skills/holded-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/holded-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/holded-webhooks/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](https://github.com/hookdeck/webhook-skills/blob/main/skills/holded-webhooks/references/overview.md) and the [Holded webhooks documentation](https://www.holded.com/en-us/developers/webhooks).

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

```bash
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](https://github.com/hookdeck/webhook-skills/blob/main/skills/holded-webhooks/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

```bash
# 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](https://github.com/hookdeck/webhook-skills/blob/main/skills/holded-webhooks/references/overview.md) - Headers, event catalogue, delivery behaviour
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/holded-webhooks/references/setup.md) - Dashboard configuration, secret, Hookdeck
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/holded-webhooks/references/verification.md) - Signature details and gotchas