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)
| 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)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| 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.