This page explains how Spotnana authenticates with your endpoint when delivering webhook events, and what your system needs to support.
Spotnana signs every webhook payload using HMAC-SHA256 protocol. This allows your endpoint to independently verify that a webhook was genuinely sent by Spotnana and that the payload was not tampered with in transit.
Note: Partners currently using the legacy OAuth 2.0 client credentials flow will continue to be supported. However, we recommend migrating the events to use HMAC signature and updating your systems to accept this signature. Reach out to your Spotnana account representative to initiate the migration.
During onboarding, Spotnana provides you with a unique webhook signing secret. This secret starts with a whsec_ prefix (e.g., whsec_a1b2c3d4...). Keep this secret secure and never expose it in client-side code or public repositories.
Every webhook delivery will include three headers that you will use to verify the payload:
| Header | Description |
|---|---|
x-spotnana-webhook-id | A unique identifier for this webhook delivery. This ID remains the same if the same webhook is re-sent. |
x-spotnana-webhook-timestamp | The Unix timestamp (in seconds) of when the webhook was sent. |
x-spotnana-webhook-signature | The Base64-encoded HMAC-SHA256 signature of the payload. May contain multiple signatures during key rotation periods, separated by spaces. |
Remove the whsec_ prefix from your signing secret, then Base64-decode the remaining string into raw bytes. This decoded value is what you will use for the cryptographic operation in step 3.
Concatenate the webhook ID, timestamp, and raw request body, each separated by a period:
{x-spotnana-webhook-id}.{x-spotnana-webhook-timestamp}.{raw-request-body}Always use the raw, unparsed HTTP request body. If your framework parses the JSON body into an object and you re-stringify it, the formatting may differ slightly and cause verification to fail.
Using the HMAC-SHA256 algorithm, hash the reconstructed string from step 2 using the decoded secret bytes from step 1. Encode the result as a Base64 string.
Split the x-spotnana-webhook-signature header by spaces to get an array of signatures. Compare your computed signature against each one using a constant-time comparison function to prevent timing attacks. If any signature matches, the webhook is valid and safe to process.
During key rotation, the x-spotnana-webhook-signature header may contain multiple signatures separated by spaces. Your verification logic should check the computed signature against each one and treat the webhook as valid if any match.
Here's a sample node.js express webhook receiver you can use to receive the webhook messages sent from Spotnana:
const express = require('express');
const crypto = require('crypto');
const app = express();
// 1. Capture the raw body string specifically for signature verification
app.use(express.json({
verify: function (req, res, buf) {
req.rawBody = buf.toString('utf8');
}
}));
app.post('/webhook-receiver', function(req, res) {
const webhookId = req.headers['x-spotnana-webhook-id'];
const webhookTimestamp = req.headers['x-spotnana-webhook-timestamp'];
const webhookSignature = req.headers['x-spotnana-webhook-signature'];
if (!webhookId || !webhookTimestamp || !webhookSignature) {
return res.status(401).json({ error: "Missing required headers" });
}
const secret = process.env.HMAC_SIGNING_SECRET;
// 2. Prepare the secret
const base64Secret = secret.startsWith('whsec_') ? secret.slice(6) : secret;
const decodedSecret = Buffer.from(base64Secret, 'base64');
try {
// 3. Reconstruct the signed payload using the raw body
const signedContent = `${webhookId}.${webhookTimestamp}.${req.rawBody}`;
// 4. Compute the expected signature
const computedSignature = crypto
.createHmac('sha256', decodedSecret)
.update(signedContent, 'utf8')
.digest('base64');
// 5. Compare the signatures securely
const signatureParts = webhookSignature.trim().split(/\s+/);
let isValid = false;
for (const receivedSignature of signatureParts) {
if (!receivedSignature) continue;
try {
if (crypto.timingSafeEqual(
Buffer.from(computedSignature, 'base64'),
Buffer.from(receivedSignature, 'base64')
)) {
isValid = true;
break;
}
} catch (e) {
// timingSafeEqual throws if buffers differ in length; skip malformed signatures
continue;
}
}
if (!isValid) {
return res.status(401).json({ error: "Invalid signature" });
}
// Process the webhook safely here
console.log("Webhook verified successfully!");
res.status(200).json({ status: "ok" });
} catch (error) {
return res.status(500).json({ error: "Verification error" });
}
});