OnlyFans + Pipedream
Pipedream fits ofapis when you want real JavaScript in a workflow but no server to host it. What makes the pairing work is not an app listing — there isn't one — it is that Pipedream hands you an HTTP endpoint with a public URL, a Node runtime with node:crypto, a key/value store and per-workflow concurrency controls. That is the complete parts list for a signed-webhook consumer, and this page builds one: an inbound message.received endpoint that verifies the signature, drops duplicate deliveries and fans out. If keeping the token and the fan records on hardware you control matters more than not running a server, the self-hosted n8n build gets to the same place on your own box.
No connected account — an environment variable instead
Connected accounts cover apps in Pipedream's registry, and ofapis is not one, so there is no OAuth handshake and no account to pick in a step. Put the token in Project Settings → Environment Variables as OFAPIS_KEY and the webhook signing secret as OFAPIS_WEBHOOK_SECRET. Both read as process.env.OFAPIS_KEY from any code step, and as {{process.env.OFAPIS_KEY}} in a no-code action's header field. Project scope beats workflow scope: one roster usually means several workflows on the same token, and rotating it should be one edit. Nothing else is needed — ofapis authentication is one Authorization: Bearer header with no request signing outbound.
The HTTP trigger and what ofapis does with its response
Add the New Requests (HTTP / Webhook Requests) trigger. Pipedream mints a URL of the form https://<id>.m.pipedream.net; register it following webhook setup.
By default the trigger returns 200 immediately and runs the rest asynchronously. Keep that default, because ofapis counts only a 2xx as delivered. Deliveries that do not get one are retried at 1m, 5m, 30m, 2h and 6h — six attempts — and an endpoint failing ten consecutive times is auto-disabled. A workflow that makes its API calls before responding will eventually hit a slow upstream and collect retries for work it already did. If you do need control over the status code, switch the trigger to a custom response and call await $.respond({ status: 200, body: "" }) yourself — useful for answering 401 to an unsigned request so a misconfigured sender fails loudly.
Verify the signature against the raw bytes
X-Ofapis-Signature arrives as t=<unix>,v1=<hex>, the HMAC-SHA256 computed over "<t>.<raw body>". The word raw is load-bearing: JSON.parse then JSON.stringify will not reproduce the delivered bytes, and the comparison fails every time.
import { createHmac, timingSafeEqual } from "node:crypto";
export default defineComponent({
async run({ steps, $ }) {
const raw = steps.trigger.event.bodyRaw;
const header = steps.trigger.event.headers["x-ofapis-signature"];
const { t, v1 } = Object.fromEntries(
header.split(",").map((p) => p.split("="))
);
const expected = createHmac("sha256", process.env.OFAPIS_WEBHOOK_SECRET)
.update(`${t}.${raw}`)
.digest("hex");
const ok =
expected.length === v1.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
if (!ok) return $.flow.exit("bad signature");
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) {
return $.flow.exit("timestamp outside tolerance");
}
return { event: JSON.parse(raw), delivery: steps.trigger.event.headers["x-ofapis-delivery"] };
},
});
The timestamp check is yours to add — nothing rejects an old t server-side, so without it a captured delivery replays forever.
A data store for dedupe keys and cursors
Delivery is at-least-once, and the payload carries no event id of its own, so X-Ofapis-Delivery is the dedupe key. Attach a data store as a prop and check it before doing anything billable:
export default defineComponent({
props: { seen: { type: "data_store" } },
async run({ steps, $ }) {
const { delivery, event } = steps.verify.$return_value;
if (await this.seen.get(delivery)) return $.flow.exit("duplicate");
await this.seen.set(delivery, Date.now(), { ttl: 86400 });
return event;
},
});
The same store is the right home for cursors. Chat history pages on olderThanMessageId rather than an offset, so a workflow reading backwards through a conversation has to remember the oldest message id it processed. Stored under a key like cursor:98765 it survives a redeploy, which a module-level variable does not.
Fan out on the payload you actually get
message.received is the only event type that exists. The body:
{
"type": "message.received",
"occurred_at": 1750000000,
"data": {
"chatId": 98765,
"message": {
"id": 111222333,
"text": "hey",
"fromUser": { "id": 98765, "username": "fan_handle" },
"toUser": { "id": 12345678, "username": "creator_handle" },
"createdAt": "2025-06-01T10:05:00+00:00"
}
}
}
There is no account id in it. If one endpoint serves several creators, route on data.message.toUser.id — the creator side of the message. chatId equals the fan's OnlyFans user id, so the reply path reuses it directly.
From the dedupe step, fan out: one branch sends the reply, one appends the fan to a list for a later campaign, one posts to Slack. Which message deserves which branch is the judgement call, not the wiring — a full bot build works through it. The reply step:
const res = await fetch(
`https://api.ofapis.com/api/public/v1/accounts/42/chats/${event.data.chatId}/messages`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OFAPIS_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": delivery,
},
body: JSON.stringify({ text: "Thanks — give me a few minutes.", price: 0 }),
}
);
if (res.status === 429) {
await $.flow.delay(Number(res.headers.get("retry-after") ?? 30) * 1000);
return { retry: true };
}
const body = await res.json();
if (!res.ok) $.flow.exit(`ofapis ${res.status} ${body.error?.code}`);
return body.data;
Two things to notice. Idempotency-Key set to the delivery id means a retried delivery replays the original response with Idempotency-Replayed: true and is not charged twice — store and key are belt and braces, and you want both. And $.flow.delay suspends the execution and resumes at the next step, so the retry belongs after the delay, not in a loop inside this one. Both of those are Pipedream's shape rather than the API's — if you are writing this much JavaScript anyway, the same flow as a plain Node service keeps its loops where you put them.
Costs matter before you fan out: that scoped message POST is 2 credits, a media upload 5, a scheduled post or message 3, most reads 1. 400, 404, 413, 415 and 422 are charged even though they failed, so a branch that keeps firing a malformed body is expensive — see error codes and cost.
Concurrency, because bursts are the normal case
A popular creator's inbound DMs do not arrive one a minute. Pipedream runs events in parallel unless told otherwise, and the ceiling you are racing is per token per minute: 60 on Free, 100 on Starter, 500 on Pro. Under Settings → Concurrency & Throttling, cap workers at a number your plan sustains; events over the cap queue rather than fail, which is what you want for a reply bot. Every response carries X-RateLimit-Limit, -Remaining and -Reset (unix seconds), so a code step can read its budget instead of guessing — see rate limits and the auto-reply bot write-up.
FAQ
Is there an ofapis app in the Pipedream registry?
No, and the workflow does not need one. Connected accounts exist to manage OAuth for registry apps; ofapis uses a static Bearer token, so a project environment variable read as process.env.OFAPIS_KEY covers both code steps and the generic HTTP action.
Why does my signature check fail even though the secret is right?
Almost always because the HMAC was computed over a re-serialised body. The signature covers "<t>.<raw body>" using the exact bytes delivered, so take the raw request body from the trigger event rather than JSON.stringify of the parsed object, and only parse afterwards.
What belongs in a Pipedream data store for this API?
Two things: delivery ids from X-Ofapis-Delivery, so an at-least-once retry does not send a second DM, and the olderThanMessageId cursor you last processed per chat. Both must outlive a single execution and a redeploy, which rules out module scope.
Does responding early change whether ofapis retries?
Yes. Only a 2xx marks a delivery as succeeded; anything else schedules a retry at 1m, 5m, 30m, 2h and 6h for up to six attempts, and ten consecutive failures disable the endpoint. Let the trigger answer immediately and do the API work afterwards.
How do I keep a DM burst from tripping the rate limit?
Set a worker cap under Concurrency & Throttling that fits your plan's per-minute ceiling, and handle 429 by reading Retry-After and calling $.flow.delay before a retry step. Queued events are not lost, so throttling costs latency, not messages — see pricing if the ceiling itself is the problem.
Related: Power Automate · n8n · webhooks · all integrations