Skip to content

Authentication

This page explains how Spotnana authenticates with your endpoint when delivering webhook events, and what your system needs to support.

HMAC signature verification

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.

Authentication procedure

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-idA unique identifier for this webhook delivery. This ID remains the same if the same webhook is re-sent.
x-spotnana-webhook-timestampThe Unix timestamp (in seconds) of when the webhook was sent.
x-spotnana-webhook-signatureThe Base64-encoded HMAC-SHA256 signature of the payload. May contain multiple signatures during key rotation periods, separated by spaces.

Step 1: Prepare your signing secret

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.

Step 2: Reconstruct the signed string

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.

Step 3: Compute the expected signature

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.

Step 4: Compare the signatures

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.

Key rotation

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.

Sample receiver

Here's a sample node.js express webhook receiver you can use to receive the webhook messages sent from Spotnana:

webhook-receiver.js
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" });
  }
});