Webhook Security
To ensure that your webhook implementation is secure, you can verify that the webhook notifications come from Ospree by computing the hmac-sha256
digest using your unique webhook secret. Each webhook URL has its own secret.
Finding your webhook secret
To get your webhook secret, first create a new webhook withConfigure Webhook or List Webhooks for existing webhooks. Then copy the webhook_secret
field from the API response.
Signature Validation
Ospree webhook notifications contain x-ospree-signature
and x-ospree-timestamp
headers for signature validation. Your application should:
Read the raw request body and the two headers before modifying the payload.
Ensure
x-ospree-timestamp
is present and within 300 seconds (5 minutes) of the current UTC time to avoid replay attacks.Parse the JSON payload and confirm it includes a
request_id
.Build the signing string as
<timestamp>.<request_id>.<raw_body>
.Compute an
HMAC-SHA256
digest of the signing string using yourWEBHOOK_SECRET
.Compare the computed digest with the hex value in
x-ospree-signature
(strip thehmac-sha256=
prefix) using a constant-time comparison.
Example Request Headers
'user-agent': 'Ospree-Webhook/1.0',
'x-ospree-signature': 'hmac-sha256=4b562a267391cffb6f5847dd5978cca8e305fb21bf1d99dafeb565a05cae6b05',
'x-ospree-timestamp': '1759839979'
Example Implementation
// server.js
const express = require('express');
const crypto = require('crypto');
require('dotenv').config();
const app = express();
const PORT = process.env.PORT || 8010;
const MAX_AGE_SECONDS = 300;
function verifyWebhook(req, res, next) {
const secret = process.env.WEBHOOK_SECRET;
if (!secret) return next(); // skip during local testing without a secret
const signatureHeader = req.get('X-Ospree-Signature');
const timestampHeader = req.get('X-Ospree-Timestamp');
const rawBody = req.body; // Buffer from express.raw
if (!signatureHeader || !timestampHeader || !Buffer.isBuffer(rawBody)) {
return res.status(400).send('Missing signature data');
}
const [algo, receivedSig] = signatureHeader.split('=', 2);
if (algo !== 'hmac-sha256') {
return res.status(400).send('Unsupported signature algorithm');
}
const timestamp = Number(timestampHeader);
if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > MAX_AGE_SECONDS) {
return res.status(400).send('Signature timestamp expired');
}
const bodyString = rawBody.toString('utf8');
const { request_id: requestId } = JSON.parse(bodyString);
if (!requestId) {
return res.status(400).send('Missing request_id');
}
const signingString = `${timestamp}.${requestId}.${bodyString}`;
const expectedSig = crypto
.createHmac('sha256', secret)
.update(signingString, 'utf8')
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(receivedSig, 'hex'), Buffer.from(expectedSig, 'hex'))) {
return res.status(400).send('Invalid webhook signature');
}
req.body = JSON.parse(bodyString); // parsed body is safe to use now
next();
}
app.post(
'/api/webhook',
express.raw({ type: 'application/json' }),
verifyWebhook,
(req, res) => {
console.log('Verified webhook payload:', req.body);
res.status(200).json({ ok: true });
}
);
app.listen(PORT, () => {
console.log(`Listening on http://localhost:${PORT}`);
});
Last updated