Tutorial 13 min read

Webhook Integration Guide: Real-Time Events with the OnlyFans API

Stop polling. Webhooks deliver new subscriber alerts, tip notifications, message events, and subscription changes to your server the moment they happen. This is the complete setup guide: endpoint configuration, signature verification, all event types, local testing with ngrok, and production-ready retry logic.

By OFAPI Team · · 13 min read

Our first integration polled the API every five minutes. Thirty API calls per creator account per hour, just to check whether anything had changed. For a ten-creator roster that was 300 calls per hour — all of them returning nothing 90% of the time. We were burning credits and adding four minutes of average latency to every real-time event we cared about.

Webhooks eliminated that entirely. Instead of asking “did anything change?” on a timer, the API tells you the moment something happens. A fan subscribes: your server receives a POST within seconds. A tip comes in: instant notification. A subscription renewal fails: you know immediately, not on the next poll cycle.

This guide covers the complete webhook setup: configuring your endpoint, verifying signatures, handling every event type, testing locally before you go to production, and building the retry logic that keeps your integration reliable when servers have bad days.

How Webhooks Work

The flow is straightforward. You give the API a URL — an HTTPS endpoint running on your server. The API makes a POST request to that URL every time a relevant event occurs, with a JSON body describing what happened. Your server processes it and returns a 200 status code to acknowledge receipt. If your server does not respond quickly enough, or returns an error, the API retries with backoff.

The critical implication: your webhook endpoint must be publicly accessible over HTTPS. A localhost URL does not work in production. We will cover the local testing pattern with ngrok in a later section — that is how you develop and debug before deploying.

Setting Up Your Express Endpoint

The webhook receiver is a standard HTTP endpoint. Here is a production-ready Express handler that covers signature verification, event routing, and acknowledgment:

import express, { Request, Response, NextFunction } from "express";
import crypto from "crypto";

const app = express();

// IMPORTANT: Use raw body for signature verification.
// express.json() parses the body — we need the raw bytes to verify the HMAC.
app.use(
  "/webhooks/onlyfans",
  express.raw({ type: "application/json" }),
  handleWebhook
);

// All other routes can use JSON parsing
app.use(express.json());

const WEBHOOK_SECRET = process.env.OFAPI_WEBHOOK_SECRET!;

function verifySignature(
  rawBody: Buffer,
  signatureHeader: string | undefined
): boolean {
  if (!signatureHeader) return false;

  // Header format: "sha256=<hex_digest>"
  const [algorithm, providedSignature] = signatureHeader.split("=");
  if (algorithm !== "sha256" || !providedSignature) return false;

  const expectedSignature = crypto
    .createHmac("sha256", WEBHOOK_SECRET)
    .update(rawBody)
    .digest("hex");

  // Use timingSafeEqual to prevent timing attacks
  const expected = Buffer.from(expectedSignature, "hex");
  const provided = Buffer.from(providedSignature, "hex");

  if (expected.length !== provided.length) return false;

  return crypto.timingSafeEqual(expected, provided);
}

async function handleWebhook(req: Request, res: Response): Promise<void> {
  const signature = req.headers["x-ofapi-signature"] as string | undefined;
  const rawBody = req.body as Buffer;

  // Verify signature before processing anything
  if (!verifySignature(rawBody, signature)) {
    console.warn("Webhook signature verification failed", {
      ip: req.ip,
      signature: signature?.slice(0, 20) + "...",
    });
    res.status(401).json({ error: "Invalid signature" });
    return;
  }

  // Parse the verified body
  let event: WebhookEvent;
  try {
    event = JSON.parse(rawBody.toString()) as WebhookEvent;
  } catch {
    res.status(400).json({ error: "Invalid JSON" });
    return;
  }

  // Acknowledge receipt immediately — do not wait for processing to complete
  // The API expects a response within ~5 seconds
  res.status(200).json({ received: true });

  // Process asynchronously after responding
  processEvent(event).catch((err) => {
    console.error("Event processing failed", { eventId: event.id, error: err });
  });
}

async function processEvent(event: WebhookEvent): Promise<void> {
  console.log("Processing webhook event", {
    id: event.id,
    type: event.type,
    creatorId: event.creatorId,
  });

  switch (event.type) {
    case "subscriber.new":
      await handleNewSubscriber(event);
      break;
    case "subscriber.renewed":
      await handleSubscriptionRenewed(event);
      break;
    case "subscriber.cancelled":
      await handleSubscriptionCancelled(event);
      break;
    case "subscriber.expired":
      await handleSubscriptionExpired(event);
      break;
    case "message.received":
      await handleMessageReceived(event);
      break;
    case "tip.received":
      await handleTipReceived(event);
      break;
    case "ppv.purchased":
      await handlePPVPurchased(event);
      break;
    default:
      console.log("Unhandled event type", { type: event.type });
  }
}

app.listen(3000, () => console.log("Webhook server running on port 3000"));

The critical detail is the express.raw() middleware on the webhook route. Signature verification requires the exact bytes the sender transmitted. Once you run the body through express.json(), the bytes are parsed into a JavaScript object — whitespace differences, key ordering, and Unicode normalization can alter the string representation, causing verification to fail even on legitimate requests. Always capture the raw body before verification.

TypeScript Types for Webhook Events

Defining the event structure upfront makes the event handlers type-safe and your IDE useful:

interface WebhookEvent {
  id: string;          // Unique event ID — use this for deduplication
  type: WebhookEventType;
  creatorId: string;
  timestamp: string;   // ISO 8601
  data: EventData;
}

type WebhookEventType =
  | "subscriber.new"
  | "subscriber.renewed"
  | "subscriber.cancelled"
  | "subscriber.expired"
  | "message.received"
  | "tip.received"
  | "ppv.purchased";

interface SubscriberData {
  fanId: string;
  username: string;
  subscriptionPrice: number;
  subscriptionDuration: number;  // days
  isRebill: boolean;
  isTrial: boolean;
  trialDays?: number;
  referralSource?: string;
}

interface MessageData {
  messageId: string;
  fanId: string;
  username: string;
  content: string;
  hasMedia: boolean;
  mediaCount?: number;
  sentAt: string;
}

interface TipData {
  fanId: string;
  username: string;
  amount: number;
  message?: string;
  context: "stream" | "post" | "message" | "profile";
  contextId?: string;
}

interface PPVData {
  fanId: string;
  username: string;
  messageId: string;
  price: number;
  mediaCount: number;
}

type EventData = SubscriberData | MessageData | TipData | PPVData;

Event Types and What to Do with Them

subscriber.new

Fires when a fan subscribes for the first time. This is the most actionable event for onboarding automation — the subscriber is engaged right now. A welcome message sent within minutes of subscription consistently outperforms one sent hours later.

async function handleNewSubscriber(event: WebhookEvent): Promise<void> {
  const data = event.data as SubscriberData;

  // Log to your CRM
  await db.fans.upsert({
    creatorId: event.creatorId,
    fanId: data.fanId,
    username: data.username,
    subscribedAt: event.timestamp,
    subscriptionSource: data.referralSource,
    isTrial: data.isTrial,
  });

  // Trigger welcome sequence — only for non-trial or after trial configures
  if (!data.isTrial) {
    await messageQueue.enqueue({
      type: "welcome_message",
      creatorId: event.creatorId,
      fanId: data.fanId,
      sendAfterSeconds: 30,  // Small delay feels more human
    });
  }

  // Update subscriber count metrics
  await metrics.increment(`creator.${event.creatorId}.new_subscribers`);

  console.log("New subscriber", {
    creator: event.creatorId,
    fan: data.username,
    price: data.subscriptionPrice,
    isTrial: data.isTrial,
  });
}

message.received

Fires when a fan sends a message. For agencies running chatter teams, this event can feed your message queue or trigger notifications to the assigned chatter.

async function handleMessageReceived(event: WebhookEvent): Promise<void> {
  const data = event.data as MessageData;

  // Store the inbound message
  await db.messages.insert({
    creatorId: event.creatorId,
    fanId: data.fanId,
    messageId: data.messageId,
    content: data.content,
    hasMedia: data.hasMedia,
    receivedAt: data.sentAt,
    status: "unread",
  });

  // Update fan's last active timestamp and message count
  await db.fans.update({
    creatorId: event.creatorId,
    fanId: data.fanId,
  }, {
    lastActiveAt: data.sentAt,
    $increment: { messageCount30d: 1 },
  });

  // Notify the assigned chatter via Slack/Discord/internal system
  const assignedChatter = await db.assignments.getChatter(
    event.creatorId,
    data.fanId
  );

  if (assignedChatter) {
    await notifyChatter(assignedChatter.id, {
      creator: event.creatorId,
      fan: data.username,
      preview: data.content.slice(0, 100),
      hasMedia: data.hasMedia,
    });
  }
}

tip.received

Fires on any tip — from a stream, a post comment, a DM, or the profile page. The context field tells you where the tip came from, which is useful for understanding which content types drive tipping behavior.

async function handleTipReceived(event: WebhookEvent): Promise<void> {
  const data = event.data as TipData;

  await db.transactions.insert({
    creatorId: event.creatorId,
    fanId: data.fanId,
    type: "tip",
    amount: data.amount,
    context: data.context,
    contextId: data.contextId,
    message: data.message,
    occurredAt: event.timestamp,
  });

  // Update fan's tip spend total
  await db.fans.update({
    creatorId: event.creatorId,
    fanId: data.fanId,
  }, {
    $increment: { tipSpend: data.amount },
  });

  // Flag high-value tips for immediate chatter attention
  if (data.amount >= 100) {
    await alertSystem.highValueTip({
      creator: event.creatorId,
      fan: data.username,
      amount: data.amount,
      message: data.message,
    });
  }
}

subscriber.cancelled and subscriber.expired

Cancellation fires when the fan explicitly turns off auto-renew. Expiration fires when the subscription actually ends. These are different signals: a cancel means you have until the expiration date to attempt a winback; an expiration means they have fully left.

async function handleSubscriptionCancelled(event: WebhookEvent): Promise<void> {
  const data = event.data as SubscriberData;

  await db.fans.update({
    creatorId: event.creatorId,
    fanId: data.fanId,
  }, {
    subscriptionStatus: "cancelled",
    cancelledAt: event.timestamp,
  });

  // Queue winback sequence — they're still subscribed until expiration
  await messageQueue.enqueue({
    type: "winback_attempt",
    creatorId: event.creatorId,
    fanId: data.fanId,
    // Send within 24 hours while still active
    sendAfterSeconds: 3600,
  });
}

async function handleSubscriptionExpired(event: WebhookEvent): Promise<void> {
  const data = event.data as SubscriberData;

  await db.fans.update({
    creatorId: event.creatorId,
    fanId: data.fanId,
  }, {
    subscriptionStatus: "expired",
    expiredAt: event.timestamp,
  });

  // Remove from active fan metrics
  await metrics.decrement(`creator.${event.creatorId}.active_subscribers`);
}

Idempotency: Handling Duplicate Deliveries

Webhook delivery is at-least-once, not exactly-once. Network timeouts, server restarts, and retry logic mean your endpoint may receive the same event multiple times. If your handler is not idempotent — meaning it produces the same result when called multiple times with the same input — you will end up with duplicate database records, duplicate notifications, and duplicate charges.

The event id field is your deduplication key:

async function processEvent(event: WebhookEvent): Promise<void> {
  // Check if we've already processed this event
  const alreadyProcessed = await db.processedEvents.exists(event.id);
  if (alreadyProcessed) {
    console.log("Duplicate event, skipping", { id: event.id });
    return;
  }

  // Mark as processed before handling — prevents double-processing
  // if the handler crashes mid-execution
  await db.processedEvents.insert({
    eventId: event.id,
    receivedAt: new Date().toISOString(),
  });

  // Route to handler
  switch (event.type) {
    case "tip.received":
      await handleTipReceived(event);
      break;
    // ... other handlers
  }
}

A simple processedEvents table with the event ID as a unique index is sufficient. If storage is a concern, add a TTL — events older than 30 days do not need deduplication protection.

Testing Locally with ngrok

You cannot test webhooks against localhost — the API cannot reach your local machine. ngrok creates a public HTTPS tunnel that forwards requests to your local server.

# Install ngrok (macOS)
brew install ngrok

# Start your local server
npx ts-node src/webhook-server.ts  # or however you start it

# In a separate terminal, start the tunnel
ngrok http 3000

ngrok will output something like:

Forwarding  https://abc123.ngrok.io -> http://localhost:3000

Use that https://abc123.ngrok.io/webhooks/onlyfans URL as your webhook endpoint when registering. The ngrok web interface at http://localhost:4040 shows every request and response in real time, including the raw headers and body — invaluable for debugging signature issues.

For automated testing without hitting the live API at all, you can replay events directly:

// test/webhook.test.ts
import crypto from "crypto";
import request from "supertest";
import { app } from "../src/app";

const WEBHOOK_SECRET = "test-secret-for-tests-only";

function buildSignedRequest(payload: object): {
  body: Buffer;
  headers: Record<string, string>;
} {
  const body = Buffer.from(JSON.stringify(payload));
  const signature = crypto
    .createHmac("sha256", WEBHOOK_SECRET)
    .update(body)
    .digest("hex");

  return {
    body,
    headers: {
      "content-type": "application/json",
      "x-ofapi-signature": `sha256=${signature}`,
    },
  };
}

describe("Webhook handler", () => {
  it("accepts a valid new subscriber event", async () => {
    const { body, headers } = buildSignedRequest({
      id: "evt_test_001",
      type: "subscriber.new",
      creatorId: "creator_abc",
      timestamp: new Date().toISOString(),
      data: {
        fanId: "fan_123",
        username: "testfan",
        subscriptionPrice: 9.99,
        subscriptionDuration: 30,
        isRebill: false,
        isTrial: false,
      },
    });

    const response = await request(app)
      .post("/webhooks/onlyfans")
      .set(headers)
      .send(body);

    expect(response.status).toBe(200);
    expect(response.body).toEqual({ received: true });
  });

  it("rejects requests with invalid signatures", async () => {
    const response = await request(app)
      .post("/webhooks/onlyfans")
      .set("content-type", "application/json")
      .set("x-ofapi-signature", "sha256=invalidsignature")
      .send(JSON.stringify({ type: "test" }));

    expect(response.status).toBe(401);
  });

  it("ignores duplicate event IDs", async () => {
    const payload = {
      id: "evt_duplicate_001",
      type: "tip.received",
      creatorId: "creator_abc",
      timestamp: new Date().toISOString(),
      data: {
        fanId: "fan_123",
        username: "testfan",
        amount: 50,
        context: "message",
      },
    };

    const { body, headers } = buildSignedRequest(payload);

    // First delivery — should process
    await request(app).post("/webhooks/onlyfans").set(headers).send(body);

    // Second delivery — should be ignored (idempotent)
    const response = await request(app)
      .post("/webhooks/onlyfans")
      .set(headers)
      .send(body);

    expect(response.status).toBe(200);
    // Verify the tip was only recorded once in your DB assertions here
  });
});

Error Handling and Retry Logic

If your endpoint returns a non-200 status code, or does not respond within the timeout window, the API will retry the delivery. The retry schedule uses exponential backoff — retries at increasing intervals before eventually marking the delivery as failed.

Your endpoint’s job is simple: return 200 quickly to acknowledge receipt, then process asynchronously. The most common mistake is doing database writes, external API calls, or anything slow synchronously before responding. If any of those slow operations takes longer than the timeout, the API marks the delivery as failed and retries — which means your slow operation runs again, possibly completing twice.

// BAD: Processing synchronously before responding
async function handleWebhookSlow(req: Request, res: Response): Promise<void> {
  const event = JSON.parse(req.body.toString());

  // This might take 3+ seconds — the API will think it failed
  await db.save(event);
  await sendSlackNotification(event);
  await updateAllMetrics(event);

  res.status(200).json({ received: true });  // Too late
}

// GOOD: Respond immediately, process asynchronously
async function handleWebhookFast(req: Request, res: Response): Promise<void> {
  // Signature verification is fast — keep it synchronous
  if (!verifySignature(req.body, req.headers["x-ofapi-signature"] as string)) {
    res.status(401).json({ error: "Invalid signature" });
    return;
  }

  const event = JSON.parse(req.body.toString());

  // Respond before doing any processing
  res.status(200).json({ received: true });

  // Process asynchronously — failures here do not affect the HTTP response
  processEvent(event).catch((err) =>
    console.error("Processing failed", { eventId: event.id, err })
  );
}

For operations that absolutely must not be lost on process crash, push the event to a durable queue (Redis, SQS, or even a database-backed queue) before responding, then process from the queue separately:

import { Queue } from "bullmq";
import Redis from "ioredis";

const redis = new Redis(process.env.REDIS_URL!);
const eventQueue = new Queue("webhook-events", { connection: redis });

async function handleWebhook(req: Request, res: Response): Promise<void> {
  if (!verifySignature(req.body, req.headers["x-ofapi-signature"] as string)) {
    res.status(401).json({ error: "Invalid signature" });
    return;
  }

  const event = JSON.parse(req.body.toString());

  // Enqueue for durable processing — fast operation
  await eventQueue.add("process", event, {
    jobId: event.id,       // Deduplication — BullMQ ignores duplicate job IDs
    attempts: 3,
    backoff: { type: "exponential", delay: 2000 },
  });

  res.status(200).json({ received: true });
}

Production Deployment Checklist

Before going live with webhooks in production:

Infrastructure

  • HTTPS endpoint only — the API will not deliver to plain HTTP
  • Public IP or domain — no localhost, no VPN-only addresses
  • Server behind a load balancer? Ensure sticky sessions or stateless processing — any instance needs the same WEBHOOK_SECRET to verify signatures
  • Graceful shutdown handling — in-flight event processing should complete before the process exits

Security

  • OFAPI_WEBHOOK_SECRET stored in environment variable, never in source code
  • Signature verification on every request before any processing
  • crypto.timingSafeEqual for signature comparison (prevents timing attacks)
  • Rate limiting on the webhook endpoint — legitimate delivery volumes are bounded
  • Request size limit configured (prevent payload flooding)

Reliability

  • Acknowledge receipt (200) before processing — never do slow work before responding
  • Deduplication table in place using event id as unique key
  • Durable queue for critical events (subscriptions, payments)
  • Dead letter queue for events that exhaust retries
  • Alerting when the dead letter queue grows

Observability

  • Log every received event with its ID, type, and creator
  • Log processing failures with event ID (so you can replay if needed)
  • Dashboard or metric for event processing latency
  • Alert on elevated error rates or processing lag

Testing

  • Unit tests covering signature verification (valid and invalid cases)
  • Unit tests for each event handler
  • Integration test using ngrok or a staging endpoint before pointing production traffic at the endpoint

Once webhooks are receiving events reliably, the next layer is error handling for the API calls your handlers make — retries, rate limit management, and circuit breaking. That is covered in the error handling patterns post.

For the full list of available endpoints and data structures, see the API documentation. To get started with API access, see the pricing page.

Get API access — start building.

Full REST API for OnlyFans automation. Get started in minutes.

Get Access →

Ready to automate your OnlyFans operations?

Get full API access and start building in minutes.