ofapisv1
capability

Updated

OnlyFans Media Upload API

One endpoint turns bytes into an id. POST /accounts/{accountId}/media takes a multipart file, pushes it into that creator's OnlyFans vault, and returns a numeric mediaId that every other content route accepts. It is also the single most expensive call in the public API at 5 credits, and it charges you before it looks at your file. That combination is the whole reason this page exists: get the request right the first time, because the second attempt costs the same as the first.

The request

POST /api/public/v1/accounts/{accountId}/media
Content-Type: multipart/form-data

The route consumes multipart/form-data and nothing else — a JSON body or a raw binary stream will not be routed to it. The file goes in a part named exactly file. There is no base64 field, no URL-fetch variant, and no unscoped form: the accountId path segment decides which creator's vault receives the bytes, so a multi-creator tool has to resolve the account before it uploads.

curl -X POST https://api.ofapis.com/api/public/v1/accounts/42/media \
  -H "Authorization: Bearer ofapis_sk_..." \
  -H "Idempotency-Key: asset-2026-03-14-promo-mp4" \
  -F "[email protected];type=video/mp4"

A success is three fields and no more:

{ "data": { "mediaId": 987654321, "contentType": "image/jpeg", "filename": "photo.jpg" } }

mediaId is the handle. contentType is the normalised type the server accepted, not necessarily the string you sent. There is no URL, no width, no duration and no ready flag here — those live on the vault item, and you read them through the vault media API.

The gate: size and type

Constraint Value
Max bytes 52,428,800 (50 MB)
Images image/jpeg, image/png, image/webp
Video video/mp4
Part name file

The type check runs against the part's declared content type, after the server strips anything following a ; and lowercases the rest. So image/jpeg; charset=binary and image/JPEG both pass, while image/jpg, image/gif, video/quicktime and application/octet-stream do not. That last one matters more than it looks: most HTTP clients derive the part type from the file extension and fall back to application/octet-stream when the extension is unfamiliar. A valid MP4 saved as clip.bin is rejected for its filename, not its bytes. Declare the type explicitly.

The two rejections are distinct and worth branching on:

{"error":{"code":"MEDIA_FILE_TOO_LARGE","message":"File size 55000000 bytes exceeds maximum allowed 52428800 bytes (50 MB)"}}
{"error":{"code":"MEDIA_TYPE_NOT_SUPPORTED","message":"Content type 'image/gif' is not supported."}}

413 means re-encode. 415 means fix the header or convert the container. Retrying the identical request fixes neither.

A rejected upload is a billed upload

Credits are debited by the gateway filter, in front of Spring's handler mapping. The order for every public call is: idempotency check, rate limit, subscription check, debit, then your request reaches the controller. Size and MIME validation live inside the controller's use case. By the time the 50 MB ceiling is evaluated, the 5 credits are already gone.

The refund rule is narrow. Only 409, 424 and any 5xx are refunded. 400, 404, 413, 415 and 422 are not. Written out for this endpoint:

Outcome Status 5 credits
Upload stored 200 spent
File over 50 MB 413 spent
Type not in the allow-list 415 spent
Account in ERROR / session expired 424 refunded
OnlyFans never indexed the file 502 refunded
Out of credits 402 never debited
Over the per-minute limit 429 never debited

The arithmetic gets unpleasant quickly. The Free plan's 250 credits are 50 successful uploads; an unattended batch job that pushes twelve 60 MB videos before anyone notices has burned 60 credits and stored nothing. The body also has to cross the wire in full before the size check fires — the servlet multipart ceiling sits far above 50 MB, so the server receives all 60 MB and then rejects it. You pay in bandwidth and in credits.

Validate locally first

A pre-flight that mirrors the server's two rules costs nothing. Check the byte length and sniff the magic bytes rather than trusting the extension — the check belongs in whatever Node wrapper your uploader already goes through:

import { open, stat } from "node:fs/promises";

const MAX = 52_428_800;
const SIGNATURES = [
  { type: "image/jpeg", at: 0, bytes: [0xff, 0xd8, 0xff] },
  { type: "image/png",  at: 0, bytes: [0x89, 0x50, 0x4e, 0x47] },
  { type: "image/webp", at: 8, bytes: [0x57, 0x45, 0x42, 0x50] },
  { type: "video/mp4",  at: 4, bytes: [0x66, 0x74, 0x79, 0x70] },
];

export async function preflight(path) {
  const { size } = await stat(path);
  if (size > MAX) throw new Error(`${path}: ${size} bytes exceeds 50 MB`);

  const fh = await open(path, "r");
  const head = Buffer.alloc(12);
  await fh.read(head, 0, 12, 0);
  await fh.close();

  const hit = SIGNATURES.find(s => s.bytes.every((b, i) => head[s.at + i] === b));
  if (!hit) throw new Error(`${path}: not jpeg/png/webp/mp4`);
  return { size, contentType: hit.type };   // feed contentType back into the part header
}

What one request actually does

The single POST you issue fans out into four upstream operations: request a signed upload slot from OnlyFans, PUT the bytes to the returned storage URL through the account's proxy, finish the multipart upload, then look the finished item up in the vault by its MD5 and byte size to recover the numeric id.

That last step is asynchronous on OnlyFans' side, so it is retried — immediately, then after 2 s, 4 s and 8 s, about 14 seconds of patience in total. If the item still has not been indexed, the call ends as 502 and the 5 credits come back. Practical consequences: set a client timeout that covers the transfer plus roughly fifteen seconds, don't treat a slow upload as a hung one, and treat a 502 as retryable in a way that 413 and 415 are not.

The file is also buffered in memory server-side for the duration. Upload sequentially rather than firing a whole folder in parallel; you will stay inside your plan's per-minute ceiling as a side effect, and those limits are on the rate limits page.

Cost in context

Call Credits
POST /accounts/{id}/media 5
POST /accounts/{id}/scheduled/posts 3
POST /accounts/{id}/chats/{chatId}/messages 2
Everything else by default 1

Uploading is five times a normal call, which is why the id it returns is worth keeping. Once stored, an asset can be attached to a feed post, a DM, a priced drop through the PPV API, or a slot three weeks out in the scheduled queue — as many times as you like, without re-transferring a byte. Plan ceilings are on pricing, and the full error catalogue on API errors.

FAQ

Why was I charged for an upload that came back 413?

Because the gateway debits credits before the handler runs, and the size check lives in the handler. Only 409, 424 and 5xx responses are refunded, and 413 is none of those. Check the file length client-side before you send it.

What content type does the file part need?

One of image/jpeg, image/png, image/webp or video/mp4, compared after the server strips any ; parameters and lowercases the value. Set it explicitly on the multipart part — clients that guess from the extension emit application/octet-stream for anything unfamiliar, which comes back 415.

How long should my client timeout be?

Long enough for the transfer plus about fifteen seconds. After the bytes land, the API polls OnlyFans for the indexed vault item with back-off at 2 s, 4 s and 8 s before giving up with a refunded 502.

Can I upload a GIF, a MOV or a 4K master?

No to the first two — only JPEG, PNG, WebP and MP4 are accepted, so transcode first. A 4K master is fine if it is under 50 MB; above that, re-encode rather than retry, since each rejected attempt costs another 5 credits.

Does an idempotency key protect an upload retry?

Yes. Send Idempotency-Key on the POST and a replay returns the original response with Idempotency-Replayed: true and no second debit. A duplicate fired while the first is still in flight gets 409 IDEMPOTENCY_CONFLICT, which costs nothing either.

Get a free token · Vault media API · API reference

Related guides

All guides
Start free — 250 credits, no card
Generate a token and make your first call in minutes.
Get started