Receiving & verifying
Each delivery is an HTTP POST with Content-Type: application/json and User-Agent: Pennylane-Webhooks. The body has a consistent envelope:
{
"id": 987654,
"event": "customer_invoice.e_invoicing_status_updated",
"retry_count": 0,
"created": 1782864000,
"data": {
"context": {
"company_id": "abc-123",
"firm_id": "firm-456"
},
"object": {
"id": 42,
"e_invoicing": { "status": "accepted" }
},
"previous_attributes": {
"e_invoicing": { "status": "submitted" }
}
}
}| Field | Description |
|---|---|
id | Unique delivery ID. Use it to deduplicate — see Delivery semantics. |
event | The event name, e.g. customer_invoice.e_invoicing_status_updated. |
retry_count | How many times this delivery has been retried (0 on first attempt). |
created | Unix epoch timestamp (seconds) of when the event occurred. |
data | The event envelope. Note: the event-specific payload is nested at data.data. |
data.context | The public company_id and firm_id the change belongs to. |
data.object | The event-specific payload — documented on each event's reference page. |
data.previous_attributes | Present only for update events; the prior values of the changed fields. |
Respond quicklyYour endpoint should return a
2xxstatus within a few seconds. Acknowledge the request first, then do heavy processing asynchronously — a slow response is treated as a timeout and the delivery will be retried.
Verify the signature
Every request carries an X-Pennylane-Signature header so you can confirm it genuinely came from Pennylane and was not tampered with:
X-Pennylane-Signature: t=1657875952,v1=5257a869e7ecebeda32aff...tis a Unix timestamp in seconds.v1is an HMAC-SHA256, in hexadecimal, of the string"{t}.{raw_request_body}", keyed with your subscription secret.
To verify: take the t value and your raw request body, recompute the HMAC, and compare it to v1 using a constant-time comparison.
Use the raw request bodyCompute the HMAC over the raw body bytes exactly as received. If you parse and re-serialize the JSON first, the bytes change and the signature will not match.
require 'openssl'
def valid_signature?(raw_body, header, secret)
parts = header.split(',').to_h { |p| p.split('=', 2) }
signed = "#{parts['t']}.#{raw_body}"
expected = OpenSSL::HMAC.hexdigest('sha256', secret, signed)
Rack::Utils.secure_compare(expected, parts['v1'])
endconst crypto = require('crypto');
function validSignature(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(',').map((p) => p.split('='))
);
const signed = `${parts.t}.${rawBody}`;
const expected = crypto
.createHmac('sha256', secret)
.update(signed)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}import hashlib
import hmac
def valid_signature(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
signed = f"{parts['t']}.".encode() + raw_body
expected = hmac.new(
secret.encode(), signed, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, parts["v1"])Optional: replay protection
The signed t timestamp also lets you reject stale or replayed deliveries. Compare t to the current time and discard requests older than a tolerance you choose (five minutes is a common default).
Delivery semantics
Webhooks reflect changes that have already been committed in Pennylane and are observed asynchronously. Design your receiver around these guarantees:
- Asynchronous. A webhook may arrive shortly after the change, not synchronously with the API call that caused it.
- Unordered. Deliveries are not guaranteed to arrive in the order the changes happened. Don't rely on arrival order; use the data in the payload (statuses, timestamps) to reconcile state.
- At-least-once. A delivery may be sent more than once — for example, if your endpoint acknowledges slowly but eventually succeeds. Deduplicate using the delivery
idand make your processing idempotent. - Respond quickly. If your endpoint doesn't return a
2xxin time, the request is treated as a failed delivery and retried.
Updated about 11 hours ago

