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.

This feature is currently in testing and will be available in production soon.

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:

  1. Read the raw request body and the two headers before modifying the payload.

  2. Ensure x-ospree-timestamp is present and within 300 seconds (5 minutes) of the current UTC time to avoid replay attacks.

  3. Parse the JSON payload and confirm it includes a request_id.

  4. Build the signing string as <timestamp>.<request_id>.<raw_body>.

  5. Compute an HMAC-SHA256 digest of the signing string using your WEBHOOK_SECRET.

  6. Compare the computed digest with the hex value in x-ospree-signature (strip the hmac-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