# Guide to Pennylane Webhooks: Features and Best Practices

Pennylane webhooks tell your integration when something changes in a Pennylane company: a customer invoice's e-invoicing status moves on, a supplier invoice arrives through e-invoicing, or a file is added to the document management system (DMS). If you build on Pennylane, the French accounting and finance platform, webhooks replace polling the API for those changes.

This guide covers how Pennylane subscriptions are created and scoped, how deliveries are signed, the events and their envelope, the status codes that switch a subscription off, and the practices that keep a Pennylane receiver reliable.

## What are Pennylane webhooks?

A Pennylane webhook subscription has an HTTPS callback URL, a list of events and a signing secret. You create it through the API v2, and Pennylane then sends a JSON `POST` to the callback URL whenever a subscribed event happens. Pennylane flags the feature as Beta and recommends falling back on its changelog endpoints during this phase.

Every delivery carries an `X-Pennylane-Signature` header in the form `t=<timestamp>,v1=<signature>`. `t` is a Unix timestamp in seconds, and `v1` is the hex HMAC-SHA256 of `{t}.{raw request body}`, keyed with the subscription's secret.

Who receives what depends on the token that created the subscription. A client-credentials OAuth token creates an app-wide subscription that delivers for every company your app can access. A company developer token creates a subscription for that one company.

## Pennylane webhook features

| Feature | Details |
| --- | --- |
| Status | Beta |
| Configuration | API only: `/api/external/v2/webhook_subscriptions` (create, list, get, update, delete) |
| Scopes | `companies:webhook_subscriptions:all` to manage, plus a read scope per event, checked at delivery time |
| Scoping | App-wide (client-credentials token) or one company (developer token) |
| Signature header | `X-Pennylane-Signature: t=<unix seconds>,v1=<hex>` |
| Scheme | HMAC-SHA256, hex |
| Signed content | `{t}.{raw body}` |
| Secret | One per subscription, returned once by the create call |
| Replay window | Optional and receiver-chosen. Pennylane calls 5 minutes "a common default" |
| Response window | `2xx` "within a few seconds" |
| Delivery | Asynchronous, unordered, at least once |
| Auto-disable | Immediately on `401`, `403`, `404` or `410`, and after repeated failures |
| Source IPs | `34.242.41.54`, `54.220.249.5`, `54.75.175.133`, `54.73.179.170`, `52.17.78.48`, `52.215.138.198` |

Pennylane doesn't publish its retry schedule or an exact timeout.

## Common events

Pennylane documents three events, and the list is likely to grow while webhooks are in Beta.

| Event | Fires when | Required scope |
| --- | --- | --- |
| `customer_invoice.e_invoicing_status_updated` | A customer invoice's e-invoicing (PDP) status changes | `customer_invoices:readonly` or `customer_invoices:all` |
| `dms_file.created` | A DMS file is created | `dms_files:readonly` or `dms_files:all` |
| `supplier_invoice.e_invoicing_received` | A supplier invoice is received via e-invoicing (the invoicing hub) | `supplier_invoices:readonly` or `supplier_invoices:all` |

The e-invoicing status can be `accepted`, `approved`, `collected`, `in_dispute`, `partially_collected`, `refused`, `rejected`, `sent` or `submitted`.

Every delivery uses the same envelope. Here is Pennylane's `customer_invoice.e_invoicing_status_updated` example:

```json
{
  "id": 987654,
  "event": "customer_invoice.e_invoicing_status_updated",
  "retry_count": 0,
  "created": 1657875952,
  "data": {
    "context": {
      "company_id": 12345,
      "firm_id": 678
    },
    "object": {
      "id": 555001,
      "e_invoicing": {
        "status": "accepted"
      }
    },
    "previous_attributes": {
      "e_invoicing": {
        "status": "submitted"
      }
    }
  }
}

```

* `id` is the unique delivery ID. Pennylane says to deduplicate on it.
* `event` is the event name. Route on it.
* `retry_count` is `0` on the first attempt and counts retries after that.
* `created` is when the event occurred, in Unix seconds.
* `data.context` names the company and firm. Its envelope example uses strings such as `"abc-123"` and the event examples use numbers, so don't assume a type.
* `data.object` is the event payload, usually little more than the resource `id`. Fetch the invoice or file from the API for the rest.
* `data.previous_attributes` appears only on update events. On the first status transition, the previous status is `null`.

Pennylane's envelope table also says the payload is "nested at `data.data`", but the same table and every example put it at `data.object`. Read `data.object`.

## Setting up Pennylane webhooks

1. Get a token with the `companies:webhook_subscriptions:all` scope. For an OAuth app, request a client-credentials token with that scope. If you get `403 Forbidden`, ask Pennylane to authorise your app for it. For a single company, create a developer token under **Company Settings > Connectivity > Developer tokens** with the **Webhook subscriptions** scope and the resource scopes your events need. A developer token's scopes can't be changed after creation.
2. Create the subscription:

```bash
curl -X POST https://app.pennylane.com/api/external/v2/webhook_subscriptions \
  -H "Authorization: Bearer $PENNYLANE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "callback_url": "https://example.com/webhooks/pennylane",
    "events": ["customer_invoice.e_invoicing_status_updated", "dms_file.created"]
  }'

```

1. Store the `secret` from the response straight away, for example as `PENNYLANE_WEBHOOK_SECRET`. Pennylane returns it only once and it can't be retrieved later.

The callback URL must be HTTPS. You can update a subscription's callback URL, events or `enabled` flag, and list or get subscriptions to see `enabled`, `disabled_at`, `disabled_reason`, `last_failure_status` and `consecutive_failures`.

The resource scope check happens when an event fires, not when you subscribe. If the credential lacks the scope at that moment, or a developer token is revoked or expired, Pennylane skips the delivery. Nothing tells your endpoint it missed an event.

If you receive Pennylane webhooks through Hookdeck, use the Hookdeck source URL as the `callback_url` and paste the subscription's `secret` into the Pennylane source.

## Securing Pennylane webhooks

Split `X-Pennylane-Signature` on commas and each part on its first `=`. Build `{t}.{raw body}` with `t` exactly as sent, compute the hex HMAC-SHA256 with the subscription secret as the key, and compare it with `v1` in constant time.

Pennylane describes a freshness check as optional replay protection, with a tolerance you choose. It is optional in the code below and off by default, and the Pennylane skill also accepts retries and replays by default, with the window behind an opt-in `PENNYLANE_TIMESTAMP_TOLERANCE_SECONDS` setting. If you turn the window on, Pennylane's retries and any retry or replay from Hookdeck keep the original `t`, so late ones fail it even though their signatures are valid.

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

function verifyPennylaneSignature(rawBody, signatureHeader, secret, toleranceSeconds = 0) {
  if (!signatureHeader || !secret) return false;
  let t;
  const signatures = [];
  for (const part of signatureHeader.split(',')) {
    const i = part.indexOf('=');
    if (i <= 0) continue;
    const key = part.slice(0, i).trim();
    const value = part.slice(i + 1).trim();
    if (key === 't') t = value;
    if (key === 'v1') signatures.push(value.toLowerCase());
  }
  if (!t || !/^\d+$/.test(t) || signatures.length === 0) return false;

  // Optional replay check, off by default.
  if (toleranceSeconds > 0 && Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) {
    return false;
  }

  const expected = Buffer.from(
    crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest('hex'),
    'utf8'
  );
  return signatures.some((signature) => {
    const received = Buffer.from(signature, 'utf8');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

const app = express();

app.post('/webhooks/pennylane', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.PENNYLANE_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyPennylaneSignature(req.body, req.get('x-pennylane-signature'), secret)) {
    return res.sendStatus(400); // not 401: a 401 disables the subscription
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on event.id, route on event.event, then process asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac
import time

def verify_pennylane_signature(raw_body: bytes, signature_header, secret: str, tolerance_seconds: int = 0) -> bool:
    if not signature_header or not secret:
        return False
    t, signatures = None, []
    for part in signature_header.split(","):
        key, sep, value = part.partition("=")
        if not sep:
            continue
        key, value = key.strip(), value.strip()
        if key == "t":
            t = value
        elif key == "v1":
            signatures.append(value.lower())
    if not t or not (t.isascii() and t.isdigit()) or not signatures:
        return False
    # Optional replay check, off by default.
    if tolerance_seconds > 0 and abs(time.time() - int(t)) > tolerance_seconds:
        return False

    expected = hmac.new(secret.encode("utf-8"), t.encode("utf-8") + b"." + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(s.encode("utf-8"), expected.encode("utf-8")) for s in signatures)

# In FastAPI: raw_body = await request.body()
# verify_pennylane_signature(raw_body, request.headers.get("x-pennylane-signature"),
#                            os.environ["PENNYLANE_WEBHOOK_SECRET"])

```

To check your verifier, use the secret `pennylane-guide-test-secret`, a `t` of `1704067200` and this exact body:

```
{"id":987656,"event":"dms_file.created","retry_count":0}

```

The header should be:

```
X-Pennylane-Signature: t=1704067200,v1=d038976866539a19225bb26cd7335ce634156992c52933466f1cacdfe16cafdf

```

If your network requires it, you can also allowlist Pennylane's six NAT gateway IPs on port 443. Pennylane describes this as an optional extra layer and recommends signature verification for authenticity.

> **Make Pennylane webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `X-Pennylane-Signature` header on every delivery, answers Pennylane within its short window, and queues each invoicing and document event until your service has processed it.

## Pennylane webhook limitations and pain points

### The wrong status code switches the subscription off

**The Problem:** Deliveries stop after a deploy or a configuration mistake, and they don't resume on their own.

**Why It Happens:** Pennylane treats `401`, `403`, `404` and `410` as permanent errors and disables the subscription at once, with `disabled_reason: permanent_error`. A verifier that returns `401` for a bad signature, an auth layer in front of your route, or a route that moved can each switch the subscription off with a single response.

**Workarounds:**

* Return `400` for a failed signature, and `5xx` for temporary problems on your side.
* Poll the subscription and alert when `enabled` becomes `false`. Re-enable it with an update once the cause is fixed.

**How Hookdeck Can Help:** Hookdeck answers Pennylane itself and keeps each delivery, so whatever status your own service returns never reaches Pennylane, and a bad deploy can't disable the subscription.

### Skipped deliveries you never see

**The Problem:** An event happens in Pennylane but your endpoint never receives it.

**Why It Happens:** Pennylane checks the resource scope when the event fires. A missing scope, a revoked developer token or an expired token without a refresh token makes Pennylane skip the delivery rather than fail it.

**Workarounds:**

* Give the credential every scope listed on the event's page before relying on the subscription.
* Rotate developer tokens deliberately, and reconcile with the changelog endpoints after any token change.

**How Hookdeck Can Help:** Hookdeck's request log shows every delivery that did arrive for each event type, so a gap in one event's traffic is easy to spot.

### The secret is shown once

**The Problem:** You lose the secret and can't verify deliveries.

**Why It Happens:** The create response is the only place the secret appears. Pennylane says it "cannot be retrieved later".

**Workarounds:**

* Store the secret in a secrets manager in the same step that creates the subscription.
* If you lose it, create a new subscription, deploy its secret, then delete the old one. Deduplicate on `id` during the overlap.

**How Hookdeck Can Help:** With Hookdeck in front, the secret lives on one source rather than in every service that handles Pennylane events.

### Beta, with a short and changing event list

**The Problem:** The webhooks don't cover everything you need, and their behaviour may change.

**Why It Happens:** The feature is in Beta, with three events. Parts of the docs disagree, such as the `data.data` note and the type of `company_id`.

**Workarounds:**

* Keep the changelog endpoints as a fallback, as Pennylane recommends, and reconcile with them periodically.
* Read `data.object`, accept both string and number IDs, and log unknown event names rather than failing.

**How Hookdeck Can Help:** Hookdeck stores the full body of every delivery, so you can check what Pennylane actually sends against what the docs say.

## Best practices

### Verify against the raw body

Pennylane warns that parsing and re-serialising the JSON changes the bytes. Use `express.raw({ type: '*/*' })` in Express, `await request.text()` in a Next.js route handler and `await request.body()` in FastAPI.

### Deduplicate on the delivery `id`

Delivery is at least once. Pennylane gives the example of an endpoint that acknowledges slowly but eventually succeeds, which then receives the delivery again. Record the envelope `id` before processing. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Don't rely on arrival order

Deliveries are unordered. Use the statuses in the payload, `previous_attributes` and `created` to reconcile state, and when in doubt fetch the invoice from the API.

### Acknowledge within a few seconds

A slow response counts as a timeout and is retried. Verify, persist, return `200`, and fetch details from the API afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Choose the right token type

For an integration used by many companies, a client-credentials token gives you one app-wide subscription and one secret. `data.context.company_id` then tells you which company each event belongs to.

## Conclusion

Pennylane signs each delivery with `X-Pennylane-Signature: t=...,v1=...`, a hex HMAC-SHA256 of the timestamp and raw body keyed with the subscription secret. Store the secret when you create the subscription, verify against the raw bytes, deduplicate on the envelope `id`, and never answer with a `401`, `403`, `404` or `410`, because any of those switches the subscription off.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks every Pennylane signature, and holds each event until your service has processed it, so a mistake on your side doesn't disable the subscription.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Pennylane webhooks reliably in minutes.