Signature Verification

Verify that webhook payloads are genuinely from GriffNode

Every webhook request includes an X-GriffNode-Signature header containing an HMAC-SHA256 signature of the raw request body, signed with your webhook secret. Always verify this before processing the event.

Never skip signature verification.

Without it, anyone can send fake events to your endpoint. Always verify before acting on a webhook.

How It Works

  1. GriffNode computes HMAC-SHA256(webhook_secret, raw_request_body)
  2. The hex digest is sent as X-GriffNode-Signature: sha256=<hex>
  3. Your server recomputes the same signature and compares

Headers Sent on Every Webhook

HeaderExampleDescription
X-GriffNode-Signaturesha256=a3f9b2...HMAC-SHA256 of raw body
X-GriffNode-Eventpayment.completedEvent type
X-Webhook-IDWHK-A1B2C3D4Your webhook endpoint ID
Content-Typeapplication/jsonAlways JSON

Verification — Node.js

Node.js (Express)
const crypto = require('crypto');

function verifyWebhookSignature(rawBody, signatureHeader, secret) {
    // signatureHeader is: "sha256=abc123..."
    const received = signatureHeader.replace('sha256=', '');
    const expected = crypto
        .createHmac('sha256', secret)
        .update(rawBody)        // rawBody must be Buffer or raw string, NOT parsed JSON
        .digest('hex');
    return crypto.timingSafeEqual(
        Buffer.from(received, 'hex'),
        Buffer.from(expected, 'hex')
    );
}

// Express example — use express.raw() to preserve the raw body
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
    const signature = req.headers['x-griffnode-signature'];
    const secret    = process.env.WEBHOOK_SECRET;

    if (!verifyWebhookSignature(req.body, signature, secret)) {
        return res.status(400).send('Invalid signature');
    }

    const event = JSON.parse(req.body);
    console.log('Event:', event.event, 'TxID:', event.transaction_id);

    // Handle the event
    switch (event.event) {
        case 'payment.completed':
            fulfillOrder(event.transaction_id);
            break;
        case 'payment.expired':
            cancelOrder(event.transaction_id);
            break;
    }

    res.status(200).json({ received: true });
});

Verification — Python

Python (Flask)
import hmac
import hashlib
from flask import Flask, request, jsonify

app = Flask(__name__)
WEBHOOK_SECRET = 'your_webhook_secret'

def verify_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
    received = signature_header.replace('sha256=', '')
    expected = hmac.new(
        secret.encode('utf-8'),
        raw_body,
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(received, expected)

@app.route('/webhook', methods=['POST'])
def webhook():
    signature = request.headers.get('X-GriffNode-Signature', '')
    raw_body  = request.get_data()  # raw bytes, before parsing

    if not verify_signature(raw_body, signature, WEBHOOK_SECRET):
        return jsonify({'error': 'Invalid signature'}), 400

    event = request.json
    print(f"Event: {event['event']}, TxID: {event['transaction_id']}")

    if event['event'] == 'payment.completed':
        fulfill_order(event['transaction_id'])
    elif event['event'] == 'payment.expired':
        cancel_order(event['transaction_id'])

    return jsonify({'received': True}), 200

Common Mistakes

Use the raw request body, not the parsed JSON. Parsing and re-serializing changes whitespace and key ordering, producing a different signature.

Responding to Webhooks

Your endpoint must return a 2xx status code within 15 seconds. Return 200 as quickly as possible — do any slow processing asynchronously. If a 2xx is not received, the delivery is logged as failed.