// Verify a GroceryAI webhook delivery. Node 18+, no dependencies.
//
// GAI_WEBHOOK_SECRET=whsec_... node verify-signature.mjs # a minimal receiver on http://localhost:8080/webhooks
//
// Every delivery is a POST with a JSON body and these headers:
// x-gai-event-id the event id (the same on every attempt: use it to drop duplicates)
// x-gai-event-type order.confirmed | support.requested | webhook.ping
// x-gai-attempt 1, 2, ... (each attempt is signed again)
// x-gai-timestamp Unix seconds when this attempt was signed
// x-gai-signature "sha256=" + hex HMAC-SHA256(secret, `${x-gai-timestamp}.${raw body}`)
//
// The secret is the endpoint's signing secret exactly as the portal showed it, prefix included ("whsec_...").
// Verify on the RAW bytes of the body: parsing and re-serialising the JSON changes them.
import crypto from 'node:crypto';
import http from 'node:http';
import { fileURLToPath } from 'node:url';
/** Our own deliveries are signed at send time; five minutes covers clock drift and slow networks. */
export const TOLERANCE_SECONDS = 300;
const header = (headers, name) => {
for (const [k, v] of Object.entries(headers)) if (k.toLowerCase() === name) return Array.isArray(v) ? v[0] : v;
return undefined;
};
/**
* @param {Buffer|string} rawBody the request body exactly as received
* @param {Record<string, string|string[]|undefined>} headers the request headers (any case)
* @param {string} secret the endpoint's signing secret, "whsec_..." included
* @returns {{ ok: true } | { ok: false, reason: 'missing_headers' | 'bad_timestamp' | 'timestamp_out_of_tolerance' | 'signature_mismatch' }}
*/
export function verifyGroceryAiSignature(rawBody, headers, secret, { toleranceSeconds = TOLERANCE_SECONDS, now = Date.now() } = {}) {
const timestamp = header(headers, 'x-gai-timestamp');
const signature = header(headers, 'x-gai-signature');
if (!timestamp || !signature) return { ok: false, reason: 'missing_headers' };
if (!/^\d+$/.test(timestamp)) return { ok: false, reason: 'bad_timestamp' };
// The timestamp is inside the signed string: an old delivery replayed later fails here, even with a valid signature.
if (Math.abs(Math.floor(now / 1000) - Number(timestamp)) > toleranceSeconds) return { ok: false, reason: 'timestamp_out_of_tolerance' };
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(String(signature));
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return { ok: false, reason: 'signature_mismatch' };
return { ok: true };
}
// A minimal receiver: verify, drop duplicates, answer 2xx fast, work afterwards.
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const secret = process.env.GAI_WEBHOOK_SECRET;
if (!secret) { console.error('set GAI_WEBHOOK_SECRET'); process.exit(1); }
const seen = new Set(); // in production: a table with a unique key on the event id
http.createServer((req, res) => {
if (req.method !== 'POST' || req.url !== '/webhooks') { res.writeHead(404).end(); return; }
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const raw = Buffer.concat(chunks);
const check = verifyGroceryAiSignature(raw, req.headers, secret);
if (!check.ok) { res.writeHead(400).end(check.reason); return; }
const event = JSON.parse(raw.toString('utf8'));
res.writeHead(200).end('ok'); // within 10 seconds, or the attempt counts as failed
if (seen.has(event.id)) return;
seen.add(event.id);
if (event.type === 'order.confirmed') console.log('order', event.data.order.id, event.environment);
else if (event.type === 'support.requested') console.log('support request', event.data.support_request.id, event.data.support_request.kind);
else if (event.type === 'webhook.ping') console.log('ping for endpoint', event.data.endpoint_id);
});
}).listen(Number(process.env.PORT ?? 8080));
}