Verifying signatures
How to prove a delivery came from Pengine before you act on it.
Because your endpoint is public, verify the signature before trusting a delivery.
X-Pengine-Signature: sha256=6f2d1c8b4a09e73f5d2b8c1a0e94f7b36d5a2c08e1f4b79d3a6c0e58b2d17f4aThe value after sha256= is the hexadecimal HMAC-SHA256 digest of the exact request body, generated
with your store's API key.
Your API key is the signing secret
There is no separate webhook secret. The same key you send in X-API-Key signs every delivery, which
means rotating it changes the signature immediately. Roll the new key out to your webhook receiver and
your API caller together. See Authentication.
The algorithm
Take the raw body
Use the exact request bytes. Parsing and reserializing JSON can change whitespace or key order and invalidate the signature. Configure your framework to preserve the raw body.
Compute the HMAC
Compute an HMAC-SHA256 digest of the raw bytes with your API key, then encode it as hexadecimal. Prefix
the result with sha256=.
Compare in constant time
Use a constant-time comparison to prevent timing attacks. Do not compare signatures with a plain string equality operator.
Reject what does not match
Return 401 and stop processing. A mismatch indicates an invalid delivery or an undeployed key
rotation.
Code
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
const app = express();
// The raw body is what was signed, so keep it as a Buffer.
app.post('/pengine-webhooks', express.raw({ type: 'application/json' }), (request, response) => {
const received = request.get('X-Pengine-Signature') ?? '';
const expected = 'sha256=' + createHmac('sha256', process.env.PENGINE_API_KEY).update(request.body).digest('hex');
const receivedBytes = Buffer.from(received);
const expectedBytes = Buffer.from(expected);
if (receivedBytes.length !== expectedBytes.length || !timingSafeEqual(receivedBytes, expectedBytes)) {
return response.status(401).end();
}
const event = JSON.parse(request.body.toString('utf8'));
// Answer first, work afterwards.
response.status(200).end();
void enqueue(event);
});What the signature does not do
The signature proves that Pengine sent the body and that it was not modified. It does not include a timestamp, so a replayed request still passes verification.
Use both of these safeguards:
- Deduplicate on the event
id. Ignore IDs you have already processed. This also handles standard webhook retries. - Refetch the resource. Acting on the current state of an order rather than on the payload makes a stale delivery harmless.