Recur Docs

Webhook verification

Verify Recur webhooks in your backend using @recur/sdk.

Webhook verification

Recur posts signed JSON to your configured webhook URL on every relevant event. Use verifyWebhookSignature and parseWebhookPayload from @recur/sdk to validate and handle incoming webhooks.

npm install @recur/sdk

Signing details

HeaderDescription
X-Recur-Signaturesha256= + hex HMAC over "${timestamp}.${rawBody}"
X-Recur-TimestampUnix seconds at dispatch time (rejected if more than 5 minutes skew)

The HMAC key is the per-app webhook secret you configure in the Recur dashboard.

Express

import express from "express";
import { verifyWebhookSignature, parseWebhookPayload } from "@recur/sdk";
 
const app = express();
const secret = process.env.RECUR_WEBHOOK_SECRET!;
 
app.post(
  "/webhooks/recur",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const body = req.body.toString();
    const signature = req.headers["x-recur-signature"] as string;
    const timestamp = req.headers["x-recur-timestamp"] as string;
 
    try {
      verifyWebhookSignature(body, signature, timestamp, secret);
    } catch {
      return res.status(401).send("Invalid signature");
    }
 
    const event = parseWebhookPayload(body);
 
    switch (event.type) {
      case "payment_success":
        // Grant access, send receipt
        break;
      case "payment_failed":
        // Notify subscriber
        break;
      case "cancel_finalized":
        // Revoke access
        break;
    }
 
    res.status(200).end();
  },
);

Next.js (App Router)

app/api/webhooks/recur/route.ts
import { verifyWebhookSignature, parseWebhookPayload } from "@recur/sdk";
 
export async function POST(req: Request) {
  const body = await req.text();
  const signature = req.headers.get("x-recur-signature")!;
  const timestamp = req.headers.get("x-recur-timestamp")!;
  const secret = process.env.RECUR_WEBHOOK_SECRET!;
 
  try {
    verifyWebhookSignature(body, signature, timestamp, secret);
  } catch {
    return new Response("Invalid signature", { status: 401 });
  }
 
  const event = parseWebhookPayload(body);
 
  switch (event.type) {
    case "subscription_created":
      // Provision user
      break;
    case "payment_success":
      // Extend access
      break;
    case "cancel_requested":
      // Mark cancelling
      break;
  }
 
  return Response.json({ ok: true });
}

Raw Node (http.IncomingMessage)

import { createServer } from "http";
import { verifyWebhookSignature, parseWebhookPayload } from "@recur/sdk";
 
const secret = process.env.RECUR_WEBHOOK_SECRET!;
 
const server = createServer(async (req, res) => {
  if (req.url === "/webhooks/recur" && req.method === "POST") {
    const chunks: Buffer[] = [];
    for await (const chunk of req) chunks.push(chunk as Buffer);
    const body = Buffer.concat(chunks).toString();
 
    try {
      verifyWebhookSignature(
        body,
        req.headers["x-recur-signature"] as string,
        req.headers["x-recur-timestamp"] as string,
        secret,
      );
      const event = parseWebhookPayload(body);
      // handle event...
      res.writeHead(200).end();
    } catch {
      res.writeHead(401).end();
    }
  }
});

Events

EventTrigger
subscription_createdInitial registration after on-chain initialize
payment_successKeeper pulled and confirmed a payment
payment_failedKeeper attempted but failed (insufficient funds, delegation exhausted)
cancel_requestedSubscriber or merchant called request_cancel
cancel_finalizedPaid period elapsed and the PDA was closed
cancel_forcedMerchant called force_cancel
delegation_revokedSubscriber manually revoked SPL Token delegation
platform_pro_activatedPlatform pro subscription activated
platform_pro_past_duePlatform pro payment overdue
platform_pro_downgradedPlatform pro downgraded due to non-payment

Recur retries failed deliveries at 1m, 5m, 30m, 2h, 12h. Your endpoint should respond 2xx within 5 seconds and dedupe on event.data.id.


Coming Soon: @recur/server — Framework-specific middleware adapters (verifyWebhookExpress, verifyWebhookNext, verifyWebhookNode) that wrap the SDK primitives above with automatic response handling. Until then, use verifyWebhookSignature and parseWebhookPayload from @recur/sdk directly as shown above.

On this page