Pengine Store API
Webhooks

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=6f2d1c8b4a09e73f5d2b8c1a0e94f7b36d5a2c08e1f4b79d3a6c0e58b2d17f4a

The 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

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

On this page