Recur Docs

Webhooks

Verify and parse Recur webhook payloads using @recur/sdk.

Webhooks

Recur posts signed JSON to your webhook URL on every subscription event. Use verifyWebhookSignature and parseWebhookPayload from @recur/sdk to validate and parse incoming requests.

Signing scheme

HeaderValue
X-Recur-Signaturesha256= + hex HMAC of "${timestamp}.${rawBody}"
X-Recur-TimestampUnix seconds at dispatch time

The HMAC key is your per-app webhook secret from the Recur dashboard. Requests older than 5 minutes (default tolerance) are rejected.

verifyWebhookSignature

import { verifyWebhookSignature } from "@recur/sdk";
 
// Throws if signature is invalid or timestamp is stale
verifyWebhookSignature(
  rawBody,        // string — raw request body
  signature,      // string — X-Recur-Signature header value
  timestamp,      // string — X-Recur-Timestamp header value
  secret,         // string — your webhook secret
  toleranceSec?,  // number — optional, default 300 (5 min)
);

parseWebhookPayload

import { parseWebhookPayload } from "@recur/sdk";
import type { WebhookPayload } from "@recur/sdk";
 
const event: WebhookPayload = parseWebhookPayload(rawBody);

Express example

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, retry logic
        break;
      case "cancel_finalized":
        // Revoke access
        break;
    }
 
    res.status(200).end();
  },
);

Next.js App Router example

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 account
      break;
    case "payment_success":
      // Extend access period
      break;
    case "cancel_requested":
      // Show "cancelling" state in UI
      break;
  }
 
  return Response.json({ ok: true });
}

Event types

EventTrigger
subscription_createdSubscription registered after on-chain initialize
payment_successKeeper pulled and confirmed a payment
payment_failedPayment attempt failed (insufficient funds, delegation exhausted)
cancel_requestedrequest_cancel called by subscriber or merchant
cancel_finalizedPrepaid period elapsed, PDA 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

Retry policy

Recur retries failed deliveries at 1m, 5m, 30m, 2h, 12h. Your endpoint should:

  • Respond 2xx within 5 seconds
  • Deduplicate on event.data.id

On this page