Metadata-Version: 2.4
Name: blu-sdk
Version: 1.0.0
Summary: BLU API client for Python. No dependencies.
Author-email: BLU <corp@blu.luxe>
License-Expression: MIT
Project-URL: Homepage, https://blu.luxe
Project-URL: Documentation, https://docs.blu.luxe/integrations/sdks/
Project-URL: Source, https://github.com/blu-luxe/blu-integrations/tree/main/sdks/python
Keywords: blu,onlyfans,agency,crm,api,webhooks,sdk
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Software Development :: Libraries
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

# BLU API for Python

Python 3.9 or later. No dependencies.

```bash
pip install blu-sdk
```

```python
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](https://docs.blu.luxe/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:

```python
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

```python
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:

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

## Errors

```python
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](https://docs.blu.luxe/outcomes/).

## Every row of a list

```python
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.

```python
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

```python
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`.
