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.
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_SECRETto verify signatures - Graceful shutdown handling — in-flight event processing should complete before the process exits
Security
-
OFAPI_WEBHOOK_SECRETstored in environment variable, never in source code - Signature verification on every request before any processing
-
crypto.timingSafeEqualfor 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
idas 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.