Gareth Wilson Gareth Wilson

Guide to Pennylane Webhooks: Features and Best Practices

Published


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

FeatureDetails
StatusBeta
ConfigurationAPI only: /api/external/v2/webhook_subscriptions (create, list, get, update, delete)
Scopescompanies:webhook_subscriptions:all to manage, plus a read scope per event, checked at delivery time
ScopingApp-wide (client-credentials token) or one company (developer token)
Signature headerX-Pennylane-Signature: t=<unix seconds>,v1=<hex>
SchemeHMAC-SHA256, hex
Signed content{t}.{raw body}
SecretOne per subscription, returned once by the create call
Replay windowOptional and receiver-chosen. Pennylane calls 5 minutes "a common default"
Response window2xx "within a few seconds"
DeliveryAsynchronous, unordered, at least once
Auto-disableImmediately on 401, 403, 404 or 410, and after repeated failures
Source IPs34.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.

EventFires whenRequired scope
customer_invoice.e_invoicing_status_updatedA customer invoice's e-invoicing (PDP) status changescustomer_invoices:readonly or customer_invoices:all
dms_file.createdA DMS file is createddms_files:readonly or dms_files:all
supplier_invoice.e_invoicing_receivedA 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:

{
  "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:
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.

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:

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

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.

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 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 for free and handle Pennylane webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.