blu-sdk 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
blu_sdk-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BLU
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
blu_sdk-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,178 @@
1
+ Metadata-Version: 2.4
2
+ Name: blu-sdk
3
+ Version: 1.0.0
4
+ Summary: BLU API client for Python. No dependencies.
5
+ Author-email: BLU <corp@blu.luxe>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://blu.luxe
8
+ Project-URL: Documentation, https://docs.blu.luxe/integrations/sdks/
9
+ Project-URL: Source, https://github.com/blu-luxe/blu-integrations/tree/main/sdks/python
10
+ Keywords: blu,onlyfans,agency,crm,api,webhooks,sdk
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Software Development :: Libraries
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Dynamic: license-file
20
+
21
+ # BLU API for Python
22
+
23
+ Python 3.9 or later. No dependencies.
24
+
25
+ ```bash
26
+ pip install blu-sdk
27
+ ```
28
+
29
+ ```python
30
+ from blu import Blu
31
+
32
+ client = Blu() # reads the key from the environment variable BLU_KEY
33
+
34
+ me = client.account.whoami()
35
+ print(me.data["keyName"])
36
+
37
+ sent = client.chats.send("1000000001", "fan_EXAMPLE0000000000", {"text": "Thanks for subscribing!"})
38
+ if sent.outcome == "unknown":
39
+ print("Check the chat before trying again:", sent.action_id)
40
+ ```
41
+
42
+ 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`.
43
+
44
+ ## Calling a method
45
+
46
+ 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:
47
+
48
+ ```python
49
+ client.chats.send(creator_id, fan_id, {"text": "Hi"}, idempotency_key="order-1234")
50
+ client.chats.list(creator_id, {"limit": 50, "order": "recent"})
51
+ client.chats.mute(creator_id, fan_id)
52
+ client.chats.send(creator_id, fan_id, {"text": "Hi"}, dry_run=True) # BLU's own checks, OnlyFans isn't asked, nothing is sent
53
+ ```
54
+
55
+ Options on any call: `timeout` (seconds of silence), `max_retries`. On a write, also `idempotency_key` and, where the endpoint has one, `dry_run`.
56
+
57
+ For asyncio, `from blu import AsyncBlu` takes the same arguments: `await client.chats.send(...)`. It runs the same client on a worker thread.
58
+
59
+ ## What comes back
60
+
61
+ ```python
62
+ res = client.posts.delete(creator_id, post_id)
63
+ res.outcome # "sent", "unknown", "scheduled", "accepted", "dry_run", or None on a read
64
+ res.data # what the endpoint answered
65
+ res.action_id # look it up later with client.account.get_action(res.action_id)
66
+ res.job_id # on "scheduled" and on a mass message
67
+ res.status_url # where to check how it went
68
+ res.replayed # True: this is the first answer again, and nothing new happened
69
+ ```
70
+
71
+ | Outcome | What to do |
72
+ |---|---|
73
+ | `sent` | Nothing. It was done. |
74
+ | `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. |
75
+ | `scheduled` | A delete waiting out its cancel window. It's a success, with a job. `client.account.cancel_job(res.job_id)` stops it. |
76
+ | `accepted` | BLU has it, and it's on its way. A mass message or a finished upload answers this. |
77
+ | `dry_run` | BLU's own checks passed, and nothing was sent. OnlyFans wasn't asked. |
78
+
79
+ ## Retries, and why a retry can't send twice
80
+
81
+ 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.
82
+
83
+ 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`.
84
+
85
+ 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.
86
+
87
+ 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:
88
+
89
+ ```python
90
+ key = "welcome-" + fan_id # survives your program restarting
91
+ client.chats.send(creator_id, fan_id, {"text": "Hi"}, idempotency_key=key)
92
+ ```
93
+
94
+ ## Errors
95
+
96
+ ```python
97
+ from blu import BluConnectionError, BluError
98
+
99
+ try:
100
+ client.chats.send(creator_id, fan_id, {"text": "Hi"})
101
+ except BluConnectionError as err:
102
+ # No answer arrived. Asking again with this key can't send twice.
103
+ print("ask again with", err.idempotency_key)
104
+ except BluError as err:
105
+ print(err.status, err.code, err.outcome, err.retryable, err.retry_after_ms, err.details, err)
106
+ ```
107
+
108
+ `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`.
109
+
110
+ Every code: [Errors & outcomes](https://docs.blu.luxe/outcomes/).
111
+
112
+ ## Every row of a list
113
+
114
+ ```python
115
+ for chat in client.chats.list_all(creator_id, {"limit": 50}):
116
+ print(chat["fanId"], chat["lastMessageTime"])
117
+ ```
118
+
119
+ 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.
120
+
121
+ `client.webhooks.pull_events_all({"since": last_id})` walks the event log the same way.
122
+
123
+ ## Webhooks
124
+
125
+ Check the raw body, before any JSON parser touches it.
126
+
127
+ ```python
128
+ import json
129
+ import os
130
+ from flask import Flask, request
131
+ from blu import verify_signature
132
+
133
+ app = Flask(__name__)
134
+
135
+ @app.post("/blu-webhook")
136
+ def receive():
137
+ raw = request.get_data()
138
+ # Anyone can reach this address, so the body can be anything.
139
+ try:
140
+ event = json.loads(raw)
141
+ except ValueError:
142
+ return "", 400
143
+ # The first challenge arrives before you have the secret. There's nothing secret in it.
144
+ if event.get("event") == "webhook.verification":
145
+ return {"challenge": event["payload"]["challenge"]}
146
+ # No secret yet means you can't check anything. A 503 makes BLU try again later.
147
+ secret = os.environ.get("BLU_WEBHOOK_SECRET")
148
+ if not secret:
149
+ return "", 503
150
+ if not verify_signature(raw, request.headers.get("BLU-Signature"), secret):
151
+ return "", 401
152
+ # Ignore an event id you've seen.
153
+ return "", 200
154
+ ```
155
+
156
+ `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`.
157
+
158
+ ## Uploading to the vault
159
+
160
+ ```python
161
+ upload = client.vault.upload(
162
+ creator_id,
163
+ file="./clip.mp4",
164
+ mime_type="video/mp4",
165
+ link_fan_id="fan_EXAMPLE0000000000", # this fan really receives the file, for free
166
+ )
167
+ print(upload["mediaId"])
168
+ ```
169
+
170
+ 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.
171
+
172
+ 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.
173
+
174
+ ## Good to know
175
+
176
+ - **Keep the key on a server.** It isn't in `repr(client)` or in an error, and this package logs nothing.
177
+ - **Redirects aren't followed**, so the key can't reach another host.
178
+ - The key defaults to the environment variable `BLU_KEY`.
@@ -0,0 +1,158 @@
1
+ # BLU API for Python
2
+
3
+ Python 3.9 or later. No dependencies.
4
+
5
+ ```bash
6
+ pip install blu-sdk
7
+ ```
8
+
9
+ ```python
10
+ from blu import Blu
11
+
12
+ client = Blu() # reads the key from the environment variable BLU_KEY
13
+
14
+ me = client.account.whoami()
15
+ print(me.data["keyName"])
16
+
17
+ sent = client.chats.send("1000000001", "fan_EXAMPLE0000000000", {"text": "Thanks for subscribing!"})
18
+ if sent.outcome == "unknown":
19
+ print("Check the chat before trying again:", sent.action_id)
20
+ ```
21
+
22
+ 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`.
23
+
24
+ ## Calling a method
25
+
26
+ 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:
27
+
28
+ ```python
29
+ client.chats.send(creator_id, fan_id, {"text": "Hi"}, idempotency_key="order-1234")
30
+ client.chats.list(creator_id, {"limit": 50, "order": "recent"})
31
+ client.chats.mute(creator_id, fan_id)
32
+ client.chats.send(creator_id, fan_id, {"text": "Hi"}, dry_run=True) # BLU's own checks, OnlyFans isn't asked, nothing is sent
33
+ ```
34
+
35
+ Options on any call: `timeout` (seconds of silence), `max_retries`. On a write, also `idempotency_key` and, where the endpoint has one, `dry_run`.
36
+
37
+ For asyncio, `from blu import AsyncBlu` takes the same arguments: `await client.chats.send(...)`. It runs the same client on a worker thread.
38
+
39
+ ## What comes back
40
+
41
+ ```python
42
+ res = client.posts.delete(creator_id, post_id)
43
+ res.outcome # "sent", "unknown", "scheduled", "accepted", "dry_run", or None on a read
44
+ res.data # what the endpoint answered
45
+ res.action_id # look it up later with client.account.get_action(res.action_id)
46
+ res.job_id # on "scheduled" and on a mass message
47
+ res.status_url # where to check how it went
48
+ res.replayed # True: this is the first answer again, and nothing new happened
49
+ ```
50
+
51
+ | Outcome | What to do |
52
+ |---|---|
53
+ | `sent` | Nothing. It was done. |
54
+ | `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. |
55
+ | `scheduled` | A delete waiting out its cancel window. It's a success, with a job. `client.account.cancel_job(res.job_id)` stops it. |
56
+ | `accepted` | BLU has it, and it's on its way. A mass message or a finished upload answers this. |
57
+ | `dry_run` | BLU's own checks passed, and nothing was sent. OnlyFans wasn't asked. |
58
+
59
+ ## Retries, and why a retry can't send twice
60
+
61
+ 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.
62
+
63
+ 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`.
64
+
65
+ 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.
66
+
67
+ 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:
68
+
69
+ ```python
70
+ key = "welcome-" + fan_id # survives your program restarting
71
+ client.chats.send(creator_id, fan_id, {"text": "Hi"}, idempotency_key=key)
72
+ ```
73
+
74
+ ## Errors
75
+
76
+ ```python
77
+ from blu import BluConnectionError, BluError
78
+
79
+ try:
80
+ client.chats.send(creator_id, fan_id, {"text": "Hi"})
81
+ except BluConnectionError as err:
82
+ # No answer arrived. Asking again with this key can't send twice.
83
+ print("ask again with", err.idempotency_key)
84
+ except BluError as err:
85
+ print(err.status, err.code, err.outcome, err.retryable, err.retry_after_ms, err.details, err)
86
+ ```
87
+
88
+ `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`.
89
+
90
+ Every code: [Errors & outcomes](https://docs.blu.luxe/outcomes/).
91
+
92
+ ## Every row of a list
93
+
94
+ ```python
95
+ for chat in client.chats.list_all(creator_id, {"limit": 50}):
96
+ print(chat["fanId"], chat["lastMessageTime"])
97
+ ```
98
+
99
+ 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.
100
+
101
+ `client.webhooks.pull_events_all({"since": last_id})` walks the event log the same way.
102
+
103
+ ## Webhooks
104
+
105
+ Check the raw body, before any JSON parser touches it.
106
+
107
+ ```python
108
+ import json
109
+ import os
110
+ from flask import Flask, request
111
+ from blu import verify_signature
112
+
113
+ app = Flask(__name__)
114
+
115
+ @app.post("/blu-webhook")
116
+ def receive():
117
+ raw = request.get_data()
118
+ # Anyone can reach this address, so the body can be anything.
119
+ try:
120
+ event = json.loads(raw)
121
+ except ValueError:
122
+ return "", 400
123
+ # The first challenge arrives before you have the secret. There's nothing secret in it.
124
+ if event.get("event") == "webhook.verification":
125
+ return {"challenge": event["payload"]["challenge"]}
126
+ # No secret yet means you can't check anything. A 503 makes BLU try again later.
127
+ secret = os.environ.get("BLU_WEBHOOK_SECRET")
128
+ if not secret:
129
+ return "", 503
130
+ if not verify_signature(raw, request.headers.get("BLU-Signature"), secret):
131
+ return "", 401
132
+ # Ignore an event id you've seen.
133
+ return "", 200
134
+ ```
135
+
136
+ `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`.
137
+
138
+ ## Uploading to the vault
139
+
140
+ ```python
141
+ upload = client.vault.upload(
142
+ creator_id,
143
+ file="./clip.mp4",
144
+ mime_type="video/mp4",
145
+ link_fan_id="fan_EXAMPLE0000000000", # this fan really receives the file, for free
146
+ )
147
+ print(upload["mediaId"])
148
+ ```
149
+
150
+ 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.
151
+
152
+ 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.
153
+
154
+ ## Good to know
155
+
156
+ - **Keep the key on a server.** It isn't in `repr(client)` or in an error, and this package logs nothing.
157
+ - **Redirects aren't followed**, so the key can't reach another host.
158
+ - The key defaults to the environment variable `BLU_KEY`.
@@ -0,0 +1,27 @@
1
+ """BLU API client. No dependencies. See README.md."""
2
+ from ._async import AsyncBlu
3
+ from ._core import Blu, BluDownload, BluResponse
4
+ from ._errors import (
5
+ BluAllowanceError,
6
+ BluAuthError,
7
+ BluBadRequestError,
8
+ BluConflictError,
9
+ BluConnectionError,
10
+ BluError,
11
+ BluNotFoundError,
12
+ BluPaginationError,
13
+ BluPermissionError,
14
+ BluRateLimitError,
15
+ BluServerError,
16
+ BluUploadError,
17
+ )
18
+ from ._generated import SDK_VERSION, SPEC_VERSION
19
+ from ._webhooks import SignatureCheck, check_signature, parse_signature_header, verify_signature
20
+
21
+ __version__ = SDK_VERSION
22
+ __all__ = [
23
+ "AsyncBlu", "Blu", "BluDownload", "BluResponse",
24
+ "BluAllowanceError", "BluAuthError", "BluBadRequestError", "BluConflictError", "BluConnectionError", "BluError",
25
+ "BluNotFoundError", "BluPaginationError", "BluPermissionError", "BluRateLimitError", "BluServerError", "BluUploadError",
26
+ "SDK_VERSION", "SPEC_VERSION", "SignatureCheck", "check_signature", "parse_signature_header", "verify_signature",
27
+ ]
@@ -0,0 +1,44 @@
1
+ """The same client for asyncio. It runs the standard-library client on a worker thread, so a call
2
+ never blocks your event loop. Methods ending in _all are async iterators."""
3
+ import asyncio
4
+
5
+ from ._core import Blu
6
+ from ._generated import GROUPS
7
+
8
+ _END = object()
9
+
10
+
11
+ async def _walk(method, args, kwargs):
12
+ iterator = await asyncio.to_thread(method, *args, **kwargs)
13
+ while True:
14
+ item = await asyncio.to_thread(next, iterator, _END)
15
+ if item is _END:
16
+ return
17
+ yield item
18
+
19
+
20
+ class _AsyncGroup:
21
+ def __init__(self, group):
22
+ self._group = group
23
+
24
+ def __getattr__(self, name):
25
+ method = getattr(self._group, name)
26
+ if name.endswith("_all"):
27
+ return lambda *args, **kwargs: _walk(method, args, kwargs)
28
+
29
+ async def call(*args, **kwargs):
30
+ return await asyncio.to_thread(method, *args, **kwargs)
31
+
32
+ return call
33
+
34
+
35
+ class AsyncBlu:
36
+ """Takes what Blu takes. await client.chats.send(...), async for row in client.chats.list_all(...)."""
37
+
38
+ def __init__(self, api_key=None, **config):
39
+ self._sync = Blu(api_key, **config)
40
+ for name in GROUPS:
41
+ setattr(self, name, _AsyncGroup(getattr(self._sync, name)))
42
+
43
+ def __repr__(self):
44
+ return "Async%r" % (self._sync,)