# Pennylane Webhooks

Pennylane is French accounting software. Its webhooks are in Beta.

## When to Use This Skill

* How do I receive Pennylane webhooks?
* How do I verify the `X-Pennylane-Signature` header?
* How do I handle `customer_invoice.e_invoicing_status_updated`, `dms_file.created`, or `supplier_invoice.e_invoicing_received`?
* Why is my Pennylane webhook subscription disabled?
* Why is my Pennylane webhook signature verification failing?

## Essential Code (USE THIS)

Every Pennylane delivery carries `X-Pennylane-Signature: t=<unix seconds>,v1=<hex>`. `v1` is the HMAC-SHA256 (hex) of `"<t>.<raw body>"`, keyed with the subscription `secret` **as-is**. Pennylane returns the secret only once, in the create-subscription response (scope `companies:webhook_subscriptions:all`), so save it then. Pennylane's docs show a single `v1` and don't describe secret rotation; the code below accepts the request if any `v1` matches (harmless either way) and rejects a header with no `t` or no `v1`.

### Verification (Node.js)

```javascript
const crypto = require('crypto');

// rawBody: Buffer of the exact request bytes (express.raw({ type: '*/*' }), not express.json)
function verifyPennylane(rawBody, header, secret) {
  if (!secret || !header) return false;
  let t;
  const v1 = [];
  for (const part of header.split(',')) {            // "t=1,v1=ab" or "t=1, v1=ab"
    const i = part.indexOf('=');
    if (i <= 0) continue;
    const key = part.slice(0, i).trim(), value = part.slice(i + 1).trim();
    if (key === 't' && t === undefined) t = value;
    else if (key === 'v1') v1.push(value);
  }
  if (!t || v1.length === 0) return false;
  // Optional replay window, off by default: Hookdeck retries and replays keep the original timestamp
  const expected = Buffer.from(
    crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest('hex'));
  // 64 hex chars first: Buffer.from(x, 'hex') silently truncates invalid hex
  return v1.some((v) => /^[0-9a-f]{64}$/i.test(v) &&
    crypto.timingSafeEqual(Buffer.from(v.toLowerCase()), expected));
}

```

### Verification (Python)

```python
import hashlib, hmac

def verify_pennylane(raw_body: bytes, header: str, secret: str) -> bool:
    if not secret or not header:
        return False
    t, v1 = None, []
    for part in header.split(","):                   # "t=1,v1=ab" or "t=1, v1=ab"
        key, sep, value = part.partition("=")
        key, value = key.strip(), value.strip()
        if not sep or not key:
            continue
        if key == "t" and t is None:
            t = value
        elif key == "v1":
            v1.append(value)
    if not t or not v1:
        return False
    expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest().encode()
    # bytes on both sides: a non-ASCII header is a mismatch (401/400), not a TypeError (500)
    return any(hmac.compare_digest(v.lower().encode(), expected) for v in v1)

```

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/pennylane-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/pennylane-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/pennylane-webhooks/examples/fastapi/).

### Timestamp window: off by default

Pennylane suggests (optionally) rejecting a `t` older than about 5 minutes. **Hookdeck retries and replays resend the original timestamp, so a 5-minute window rejects late retries.** The examples therefore skip the freshness check unless you set `PENNYLANE_TIMESTAMP_TOLERANCE_SECONDS` to a positive number of seconds (default `0` = off). Deduplicate on the delivery `id` instead. [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/pennylane-webhooks/references/verification.md) shows exactly where the window can be dropped or widened.

### Status codes

Return **400** for a bad signature. Pennylane disables the subscription on a permanent delivery error (HTTP 401, 403, 404 or 410), whereas other failures are retried (Pennylane doesn't document the schedule) until repeated failures over a sustained period disable it too. Return 2xx within a few seconds, then process asynchronously.

## Event Types

Unlike some providers, the event name is in the body (`event`). The payload is under `data.object`:

| Event | Triggered When | Notes |
| --- | --- | --- |
| `customer_invoice.e_invoicing_status_updated` | A customer invoice's e-invoicing (PDP) status changes | `data.previous_attributes` holds the previous status (`null` on the first transition) |
| `dms_file.created` | A DMS file is created | Create event, no `previous_attributes` |
| `supplier_invoice.e_invoicing_received` | A supplier invoice arrives through e-invoicing | Create event, no `previous_attributes` |

Envelope: `id` (delivery id, use it to deduplicate), `event`, `retry_count`, `created` (unix seconds), `data.context` (`company_id`, `firm_id`), `data.object`. Deliveries are at-least-once and not ordered. See [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/pennylane-webhooks/references/overview.md).

## Important Headers

| Header | Description |
| --- | --- |
| `X-Pennylane-Signature` | `t=<unix seconds>,v1=<hex HMAC-SHA256>` |
| `User-Agent` | `Pennylane-Webhooks` |

## Environment Variables

```bash
PENNYLANE_WEBHOOK_SECRET=your_subscription_secret   # secret returned once by the create-subscription call
PENNYLANE_TIMESTAMP_TOLERANCE_SECONDS=0             # optional; a positive value opts in to the replay window

```

## Local Development

```bash
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 pennylane --path /webhooks/pennylane

```

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/pennylane-webhooks/references/overview.md) - Pennylane webhook concepts, events, payload, retries
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/pennylane-webhooks/references/setup.md) - Creating the subscription through the API and saving the secret
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/pennylane-webhooks/references/verification.md) - Signature details, the timestamp window, and debugging