Skip to main content

Overview

The WebhooksClient provides methods for verifying inbound webhooks from Revstack Cloud. It uses HMAC-SHA256 with constant-time comparison to prevent timing attacks, plus timestamp validation for replay protection.

Methods

constructEvent()

Verify the signature of an incoming webhook and parse the event payload.
const event = revstack.webhooks.constructEvent(
  payload,
  signature,
  secret,
  tolerance
);
payload
string | Buffer
required
Raw request body. Must NOT be parsed JSON.Use express.raw({ type: "application/json" }) or equivalent to preserve the raw body.
signature
string
required
Value of the revstack-signature HTTP header.Format: t=<timestamp>,v1=<signature>
secret
string
required
Webhook signing secret from the Revstack Dashboard.Example: "whsec_..."
tolerance
number
Maximum age of the webhook in seconds.Default: 300 (5 minutes)Set to 0 to disable replay protection (not recommended).
event
WebhookEvent
The verified and parsed webhook event.
Throws:
  • SignatureVerificationError - If the signature is invalid, the header is malformed, or the timestamp exceeds the tolerance.

Usage Examples

Express.js

import express from "express";
import { Revstack, SignatureVerificationError } from "@revstackhq/node";

const app = express();
const revstack = new Revstack({ secretKey: process.env.REVSTACK_SECRET_KEY! });

// Use express.raw() to preserve the raw body
app.post(
  "/webhooks/revstack",
  express.raw({ type: "application/json" }),
  (req, res) => {
    try {
      const event = revstack.webhooks.constructEvent(
        req.body,
        req.headers["revstack-signature"] as string,
        process.env.REVSTACK_WEBHOOK_SECRET!
      );
      
      console.log(`Received event: ${event.type}`);
      
      // Handle the event
      switch (event.type) {
        case "subscription.created":
          await handleSubscriptionCreated(event.data);
          break;
        case "invoice.paid":
          await handleInvoicePaid(event.data);
          break;
        default:
          console.log(`Unhandled event type: ${event.type}`);
      }
      
      res.sendStatus(200);
    } catch (error) {
      if (error instanceof SignatureVerificationError) {
        console.error("Invalid signature:", error.message);
        res.status(400).send("Invalid signature");
      } else {
        console.error("Webhook error:", error);
        res.status(500).send("Webhook processing failed");
      }
    }
  }
);

Next.js API Route

// pages/api/webhooks/revstack.ts
import { NextApiRequest, NextApiResponse } from "next";
import { Revstack, SignatureVerificationError } from "@revstackhq/node";
import { buffer } from "micro";

const revstack = new Revstack({ secretKey: process.env.REVSTACK_SECRET_KEY! });

// Disable Next.js body parsing
export const config = {
  api: {
    bodyParser: false,
  },
};

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (req.method !== "POST") {
    return res.status(405).send("Method not allowed");
  }
  
  try {
    const rawBody = await buffer(req);
    const signature = req.headers["revstack-signature"] as string;
    
    const event = revstack.webhooks.constructEvent(
      rawBody,
      signature,
      process.env.REVSTACK_WEBHOOK_SECRET!
    );
    
    // Handle the event
    await handleWebhookEvent(event);
    
    res.status(200).json({ received: true });
  } catch (error) {
    if (error instanceof SignatureVerificationError) {
      return res.status(400).send("Invalid signature");
    }
    console.error("Webhook error:", error);
    res.status(500).send("Internal server error");
  }
}

Fastify

import Fastify from "fastify";
import { Revstack, SignatureVerificationError } from "@revstackhq/node";

const fastify = Fastify();
const revstack = new Revstack({ secretKey: process.env.REVSTACK_SECRET_KEY! });

fastify.post(
  "/webhooks/revstack",
  {
    config: {
      rawBody: true, // Preserve raw body
    },
  },
  async (request, reply) => {
    try {
      const event = revstack.webhooks.constructEvent(
        request.rawBody!,
        request.headers["revstack-signature"] as string,
        process.env.REVSTACK_WEBHOOK_SECRET!
      );
      
      console.log(`Event: ${event.type}`);
      
      // Process the event
      await processWebhook(event);
      
      reply.code(200).send({ ok: true });
    } catch (error) {
      if (error instanceof SignatureVerificationError) {
        reply.code(400).send({ error: "Invalid signature" });
      } else {
        reply.code(500).send({ error: "Internal error" });
      }
    }
  }
);

Event Types

Revstack sends the following webhook events:

Customer Events

  • customer.created - New customer created
  • customer.updated - Customer information updated
  • customer.deleted - Customer deleted

Subscription Events

  • subscription.created - New subscription started
  • subscription.updated - Subscription modified (plan change, etc.)
  • subscription.canceled - Subscription canceled
  • subscription.trial_ending - Trial period ending soon

Invoice Events

  • invoice.created - New invoice generated
  • invoice.paid - Invoice successfully paid
  • invoice.payment_failed - Payment failed
  • invoice.voided - Invoice voided

Entitlement Events

  • entitlement.limit_reached - Customer reached their usage limit
  • entitlement.limit_exceeded - Customer exceeded their usage limit

Best Practices

1. Always Verify Signatures

Never process webhooks without verification:
// ❌ Bad: Processing unverified webhooks
app.post("/webhooks", (req, res) => {
  const event = req.body; // Unverified!
  processEvent(event);
});

// ✅ Good: Always verify
app.post("/webhooks", express.raw({ type: "application/json" }), (req, res) => {
  const event = revstack.webhooks.constructEvent(
    req.body,
    req.headers["revstack-signature"],
    secret
  );
  processEvent(event);
});

2. Use Raw Body

The signature is computed over the raw request body:
// ❌ Bad: Body already parsed
app.use(express.json()); // Parses all routes
app.post("/webhooks", (req, res) => {
  const event = revstack.webhooks.constructEvent(
    req.body, // Already parsed, verification will fail!
    ...
  );
});

// ✅ Good: Preserve raw body for webhook route
app.post(
  "/webhooks",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const event = revstack.webhooks.constructEvent(
      req.body, // Raw Buffer
      ...
    );
  }
);

3. Handle Errors Gracefully

try {
  const event = revstack.webhooks.constructEvent(...);
  await processEvent(event);
  res.sendStatus(200);
} catch (error) {
  if (error instanceof SignatureVerificationError) {
    // Invalid signature - reject the request
    console.error("Webhook signature verification failed:", error.message);
    return res.status(400).send("Invalid signature");
  }
  // Other errors - still acknowledge receipt to avoid retries
  console.error("Webhook processing error:", error);
  res.sendStatus(500);
}

4. Implement Idempotency

Webhooks may be delivered multiple times:
const processedEvents = new Set<string>();

app.post("/webhooks", async (req, res) => {
  const event = revstack.webhooks.constructEvent(...);
  
  // Check if already processed
  if (processedEvents.has(event.id)) {
    return res.sendStatus(200); // Already handled
  }
  
  await processEvent(event);
  processedEvents.add(event.id);
  
  res.sendStatus(200);
});

5. Respond Quickly

Return 200 quickly and process asynchronously:
app.post("/webhooks", async (req, res) => {
  const event = revstack.webhooks.constructEvent(...);
  
  // Queue for async processing
  await queue.add("webhook", { event });
  
  // Respond immediately
  res.sendStatus(200);
});

Build docs developers (and LLMs) love