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
- Loyva signs the raw request body with your webhook secret using HMAC-SHA256
- The signature is sent as
sha256=<hex>inX-Loyva-Signature - 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 onevent_idto 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
timestampfrom 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 originaltimestamp, and the retry schedule runs to roughly an hour. -
If
X-Loyva-Signatureis 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:
| Header | Signed with |
|---|---|
X-Loyva-Signature | The new secret |
X-Loyva-Signature-Previous | The outgoing secret |
That lets you deploy the new secret without dropping events. The safe sequence is:
- Rotate, and store the new secret alongside the one already deployed.
- Accept a request if either header verifies.
- 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.