Skip to main content

BLU API for Python

Python 3.9 or later. No dependencies.

pip install blu-sdk
from blu import Blu

client = Blu()  # reads the key from the environment variable BLU_KEY

me = client.account.whoami()
print(me.data["keyName"])

sent = client.chats.send("1000000001", "fan_EXAMPLE0000000000", {"text": "Thanks for subscribing!"})
if sent.outcome == "unknown":
    print("Check the chat before trying again:", sent.action_id)

Every endpoint is a method on a group, in the same order as the API reference: client.chats, client.fans, client.vault, client.mass, client.posts, client.links, client.analytics, client.account, client.webhooks, client.creators, client.employees, client.org.

Calling a method

The path's ids come first, then the body or the query as a dict, with the API's own field names. Options are keywords:

client.chats.send(creator_id, fan_id, {"text": "Hi"}, idempotency_key="order-1234")
client.chats.list(creator_id, {"limit": 50, "order": "recent"})
client.chats.mute(creator_id, fan_id)
client.chats.send(creator_id, fan_id, {"text": "Hi"}, dry_run=True)  # BLU's own checks, OnlyFans isn't asked, nothing is sent

Options on any call: timeout (seconds of silence), max_retries. On a write, also idempotency_key and, where the endpoint has one, dry_run.

For asyncio, from blu import AsyncBlu takes the same arguments: await client.chats.send(...). It runs the same client on a worker thread.

What comes back

res = client.posts.delete(creator_id, post_id)
res.outcome      # "sent", "unknown", "scheduled", "accepted", "dry_run", or None on a read
res.data         # what the endpoint answered
res.action_id    # look it up later with client.account.get_action(res.action_id)
res.job_id       # on "scheduled" and on a mass message
res.status_url   # where to check how it went
res.replayed     # True: this is the first answer again, and nothing new happened
Outcome What to do
sent Nothing. It was done.
unknown Never resend it automatically. OnlyFans may have done it. Read the chat first. If you still want to send it, make a new call.
scheduled A delete waiting out its cancel window. It's a success, with a job. client.account.cancel_job(res.job_id) stops it.
accepted BLU has it, and it's on its way. A mass message or a finished upload answers this.
dry_run BLU's own checks passed, and nothing was sent. OnlyFans wasn't asked.

Retries, and why a retry can't send twice

Every write gets an Idempotency-Key, generated if you give none and kept across that call's own retries. BLU answers a repeat with the first answer, so the fan gets one message.

It retries a dropped connection, a timeout, a 429 and a 503, waiting at least as long as Retry-After says, or backing off with jitter. It stops after max_retries (default 3). A wait longer than max_retry_delay (default 60 seconds) isn't waited for: the error reaches you with retry_after_ms.

It never retries a 2xx, so an unknown is never resent. It never retries an error that carries an outcome. The API ignores the key on a few webhook calls, such as rotate_secret, so those go again only when BLU itself says to try later, never after a lost answer: a second rotation deletes the secret you hold.

A new call is a new action with a new key, so wrap a call in your own retry loop only if you pass the same idempotency_key each time:

key = "welcome-" + fan_id  # survives your program restarting
client.chats.send(creator_id, fan_id, {"text": "Hi"}, idempotency_key=key)

Errors

from blu import BluConnectionError, BluError

try:
    client.chats.send(creator_id, fan_id, {"text": "Hi"})
except BluConnectionError as err:
    # No answer arrived. Asking again with this key can't send twice.
    print("ask again with", err.idempotency_key)
except BluError as err:
    print(err.status, err.code, err.outcome, err.retryable, err.retry_after_ms, err.details, err)

err.code is stable: use it in your code. err.outcome is not_sent when nothing happened, and then a new call is safe. Classes: BluBadRequestError (400, 422), BluAuthError (401), BluAllowanceError (402), BluPermissionError (403), BluNotFoundError (404), BluConflictError (409), BluRateLimitError (429), BluServerError (5xx), BluConnectionError, BluPaginationError, BluUploadError. All are a BluError.

Every code: Errors & outcomes.

Every row of a list

for chat in client.chats.list_all(creator_id, {"limit": 50}):
    print(chat["fanId"], chat["lastMessageTime"])

Any list that pages has a ..._all method, and with AsyncBlu it's an async iterator: async for chat in client.chats.list_all(...). Only hasMore: false ends it: a short or empty page doesn't. A list that says there's more and gives no way to get it raises BluPaginationError after its rows, because stopping quietly would lose some. Each page of a live list uses one live call, so break when you have enough.

client.webhooks.pull_events_all({"since": last_id}) walks the event log the same way.

Webhooks

Check the raw body, before any JSON parser touches it.

import json
import os
from flask import Flask, request
from blu import verify_signature

app = Flask(__name__)

@app.post("/blu-webhook")
def receive():
    raw = request.get_data()
    # Anyone can reach this address, so the body can be anything.
    try:
        event = json.loads(raw)
    except ValueError:
        return "", 400
    # The first challenge arrives before you have the secret. There's nothing secret in it.
    if event.get("event") == "webhook.verification":
        return {"challenge": event["payload"]["challenge"]}
    # No secret yet means you can't check anything. A 503 makes BLU try again later.
    secret = os.environ.get("BLU_WEBHOOK_SECRET")
    if not secret:
        return "", 503
    if not verify_signature(raw, request.headers.get("BLU-Signature"), secret):
        return "", 401
    # Ignore an event id you've seen.
    return "", 200

client.webhooks.create answers the secret once. If its first answer was lost, the retry comes back with secret set to None and replayed true: call client.webhooks.rotate_secret to get a new one. During a rotation, pass both secrets: verify_signature(raw, header, [old_secret, new_secret]). tolerance_seconds defaults to 300. check_signature takes the same arguments and says why: malformed, too_old or no_match.

Uploading to the vault

upload = client.vault.upload(
    creator_id,
    file="./clip.mp4",
    mime_type="video/mp4",
    link_fan_id="fan_EXAMPLE0000000000",  # this fan really receives the file, for free
)
print(upload["mediaId"])

It creates the upload, sends parts of the size the API answers, completes it and waits until the file is at OnlyFans. A file on disk is read one part at a time. For a file object, also pass file_name and file_size_bytes: it holds one part in memory, because a part that fails has to go again. wait=False returns once it's on its way, and on_progress reports each part.

If it stops before the file is sent, it cancels the upload, which gives back the credits and the creator's one upload slot. BluUploadError carries outcome. An unknown there means the file may already be with the fan: don't upload it again without checking.

Good to know

  • Keep the key on a server. It isn't in repr(client) or in an error, and this package logs nothing.
  • Redirects aren't followed, so the key can't reach another host.
  • The key defaults to the environment variable BLU_KEY.

Metadata

Release files for blu-sdk 1.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for blu-sdk 1.0.0
File Size Uploaded Provenance Actions
blu_sdk-1.0.0.tar.gz 53.1 kB Without provenance Details for blu_sdk-1.0.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for blu-sdk 1.0.0
File Interpreter ABI Platform Actions
blu_sdk-1.0.0-py3-none-any.whl Python 3 none any Details for blu_sdk-1.0.0-py3-none-any.whl

Total release size: 94.2 kB

Release files / blu_sdk-1.0.0.tar.gz

Download URL blu_sdk-1.0.0.tar.gz
Size 53.1 kB
Tags Source
SHA-256 checksum
How to use checksums
d070ee8c2ea1e8807affd3a90b8be8e71fd2dec0146aed3cfcc6228a78cbd464
BLAKE2b-256 checksum
How to use checksums
911c3e37a653eaf4507fbbfd77d82c2a063cd896049fe94b171442260ec71769
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.16

Release files / blu_sdk-1.0.0-py3-none-any.whl

Download URL blu_sdk-1.0.0-py3-none-any.whl
Size 41.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
2dec210372ce9b3bfa748e0d7b0eac2e48a012136f594e2d8a09ac9d2e957fc1
BLAKE2b-256 checksum
How to use checksums
6875b87754268442c23c0647821c438533d4835339dc2cf7665a72cdf0a8895c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.16

Security for blu-sdk 1.0.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

1.0.0 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page