Skip to main content

Webhook Verification

Every webhook request from a signed endpoint includes an HMAC-SHA256 signature in the X-Loyva-Signature header. Always verify it before processing the webhook.

Each endpoint has its own signing secret, returned once when the endpoint is created or its secret is rotated. Use X-Loyva-Endpoint to look up the right secret when one receiver serves several endpoints.

Signature format

The header value is always prefixed with sha256=:

X-Loyva-Signature: sha256=a1b2c3d4e5f6...

Strip the prefix before comparing. The signature itself is the lowercase hex-encoded HMAC-SHA256 of the raw request body using that endpoint's signing secret.

How verification works

  1. Loyva signs the raw request body with your webhook secret using HMAC-SHA256
  2. The signature is sent as sha256=<hex> in X-Loyva-Signature
  3. You recompute the signature over the raw body and compare with timing-safe equality

Implementation

Node.js (Express)

import crypto from 'crypto';

function verifyWebhookSignature(rawBody, signatureHeader, secret) {
if (!signatureHeader?.startsWith('sha256=')) return false;
const provided = signatureHeader.slice('sha256='.length);

const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');

const a = Buffer.from(provided, 'hex');
const b = Buffer.from(expected, 'hex');
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}

// IMPORTANT: use express.raw so req.body is the exact bytes Loyva signed
app.post(
'/webhooks/loyva',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-loyva-signature'];
const valid = verifyWebhookSignature(
req.body,
signature,
process.env.LOYVA_WEBHOOK_SECRET,
);

if (!valid) return res.status(401).json({ error: 'Invalid signature' });

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

const event = JSON.parse(req.body.toString('utf8'));
handleWebhook(event);
},
);

Python

import hmac
import hashlib

def verify_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
if not signature_header or not signature_header.startswith("sha256="):
return False
provided = signature_header.removeprefix("sha256=")
expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(provided, expected)

Go

func verifySignature(rawBody []byte, signatureHeader, secret string) bool {
if !strings.HasPrefix(signatureHeader, "sha256=") {
return false
}
provided := strings.TrimPrefix(signatureHeader, "sha256=")

mac := hmac.New(sha256.New, []byte(secret))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))

return hmac.Equal([]byte(provided), []byte(expected))
}

Important notes

  • Always use timing-safe comparison to prevent timing attacks

  • Verify against the raw request body bytes — parsed JSON may be re-serialized differently and invalidate the signature

  • Remember to strip the sha256= prefix before comparing

  • Retries reuse the same event_id; dedupe on event_id to ensure idempotent processing

  • Reject stale deliveries. A valid signature stays valid forever, so a captured request can be replayed at any time. The signed body carries timestamp (ISO-8601, set when the event was emitted) — after the signature verifies, drop anything outside a tolerance window:

    const skewMs = Math.abs(Date.now() - Date.parse(event.timestamp));
    if (!Number.isFinite(skewMs) || skewMs > 5 * 60 * 1000) {
    return res.status(400).json({ error: 'Stale webhook' });
    }

    Read timestamp from the verified body, never from an unsigned header. Widen the window if your receiver can be offline long enough for retries to age out — retries keep the original timestamp, and the retry schedule runs to roughly an hour.

  • If X-Loyva-Signature is missing, that endpoint has no signing secret. This happens only for endpoints migrated from the older per-API-key configuration, where a secret was never set. The console flags them as Not signed — rotate the secret to start signing.

Rotating a secret without downtime

Rotating issues a new secret and opens a 48-hour grace window. During it, every delivery carries two signatures:

HeaderSigned with
X-Loyva-SignatureThe new secret
X-Loyva-Signature-PreviousThe outgoing secret

That lets you deploy the new secret without dropping events. The safe sequence is:

  1. Rotate, and store the new secret alongside the one already deployed.
  2. Accept a request if either header verifies.
  3. Deploy the new secret, then drop the old one.
function verifyAny(rawBody, headers, secrets) {
const candidates = [headers['x-loyva-signature'], headers['x-loyva-signature-previous']]
.filter(Boolean)
return candidates.some(sig => secrets.some(s => verifyWebhookSignature(rawBody, sig, s)))
}

After the window closes only X-Loyva-Signature is sent, and only the new secret verifies. If you miss the window, rotate again — there is no way to read an existing secret back.