Remote API
API referenceBeta
Endpoints of the Fluffer Remote API: status, vibrate, rotate, patterns, stop, tips, fuel and command results, with parameters, responses and errors.
Base URL: https://api.fluffer.app/v1
Every request needs the header Authorization: Bearer flk_…. Send JSON bodies with Content-Type: application/json (an empty body is fine where nothing is required). Browsers may call the API too (CORS is open), but only from places you keep private: anyone who can read a page’s source can read a key in it.
#GET /me
Checks a key and returns what it may do.
{ "key_id": "0d3e…", "scopes": ["device.control", "tips.publish"] }
#GET /status
Whether the user’s Fluffer app is listening, and what their toy is doing. Scope: device.status.
{
"listening": true,
"paused": false,
"last_seen": "2026-10-13T18:21:04.512Z",
"toy": { "type": "Buck 2", "battery": 80 },
"motors": [
{ "index": 1, "kind": "vibe", "on": true, "level": 0.6, "until": "2026-10-13T18:21:07.512Z" },
{ "index": 2, "kind": "twirl", "on": false, "level": 0.5, "until": null }
],
"app_version": "3.1.0"
}
| Field | Meaning |
|---|---|
listening | The app is open with Remote control on and reported in within the last 90 seconds. |
paused | The user tapped Stop; motion is ignored until they tap Resume. |
last_seen | When the app last reported in, or null if it never has. |
toy | type (the model family, e.g. "Buck 2", "Motorbunny", or "phone" for the phone’s own vibration) and battery 0–100 or null. Null when no toy is connected. |
motors | Up to 4: index 1–4, kind (vibe, twirl, thrust, thrust-side), on (false when the user switched it off in the app), level 0–1 (twirl: 0.5 is still), until (when it stops) or null. |
app_version | The Fluffer app’s version, or null. |
Nothing personal is in it: no names, emails, locations or other apps.
#GET /commands/{id}
What the app did with a command, by the id from its 202. A key with device.status can read any of its user’s commands; any other key only the ones it sent. Anything else is 404.
{ "id": "6f1c0d7e-…", "action": "vibrate", "created_at": "…", "result": "played", "reason": null, "handled_at": "…" }
| Field | Values |
|---|---|
result | null until the app handles it, then played, partly (some of it played) or refused. It never changes once set. |
reason | paused, motor_off, no_toy, not_listening, power_limit, expired, or null. |
handled_at | When the app handled it, or null. |
The user can switch any motor off with its power button in the app. A command that needs a motor that’s off comes back partly (the other motors played) or refused (nothing played), with reason motor_off. Old commands are cleared after a while (motion after an hour, tips and fuel after 7 days); then this returns 404.
#POST /vibrate
Vibrates the toy for a while, then stops. Scope: device.control.
| Field | Type | Notes |
|---|---|---|
strength | number 0–1 | All motors. Use this or the per-motor fields. |
m1 … m4 | number 0–1 | Per motor; any you leave out follow m1. |
duration_ms | number | 50 to 900000 (15 minutes). Default 5000. |
{ "strength": 0.4, "duration_ms": 10000 }
Sending a new vibrate replaces the previous one. To change strength smoothly, send updates as often as you like within the rate limit.
#POST /rotate
Rotation (“twirl”) for toys that have it. Scope: device.control.
| Field | Type | Notes |
|---|---|---|
speed | number −1–1 | The sign is the direction. Required. |
duration_ms | number | 50 to 900000. Default 5000. |
#POST /pattern
Plays a named pattern. Scope: device.control.
| Field | Type | Notes |
|---|---|---|
name | string | pulse, impact, tap, wave, rampUp, rampDown, fade or heartbeat |
params | object | Optional numbers, below. Unknown ones are ignored. |
| Param | Range | Used by |
|---|---|---|
strength | 0–1 | pulse, impact, tap, heartbeat |
amplitude | 0–1 | wave |
from, to | 0–1 | rampUp, rampDown |
fromStrength | 0–1 | fade |
durationMs | 10–900000 | pulse, wave, rampUp, rampDown, fade, heartbeat |
frequencyHz | 0.1–10 | wave |
bpm | 30–200 | heartbeat |
steps | 1–100 | rampUp, rampDown |
A heartbeat without durationMs plays for 15 minutes; send stop to end it sooner.
{ "name": "wave", "params": { "amplitude": 0.8, "frequencyHz": 0.5, "durationMs": 60000 } }
#POST /stop
Stops whatever the API started. Any key may call it; no body.
#POST /tip
Sends a tip to the user’s Fluffer. It feeds their Tip4Fuel exactly like a Chaturbate tip: it adds fuel by their own token-to-fuel settings, shows in their tip history with an API badge, and reaches games they’ve allowed to see tips. With is_anon (or no tipper), no name is stored or shown. Works while the user’s Remote control mode is Tips only or Tips + control. Scope: tips.publish.
| Field | Type | Notes |
|---|---|---|
tokens | whole number | 1 to 100000. Required. |
tipper | string | Up to 60 characters. |
message | string | Up to 280 characters. |
is_anon | boolean | Hide the tipper’s name. |
external_id | string | Your id for this tip (up to 128 characters). Sending the same id again is recorded once, so retries are safe. |
#POST /fuel
Adds fuel straight to the user’s Tip4Fuel tank (no tip history entry). Games they’ve allowed to see tips get it as a fuel change with source api. Scope: device.fuel.
| Field | Type | Notes |
|---|---|---|
amount | number | More than 0, up to 100. |
#Responses
A successful command returns 202 with its id (for GET /commands/{id}) and the command as Fluffer will play it (after limits are applied):
{ "id": "…", "action": "pattern", "kind": "haptic_pattern", "payload": { "name": "wave", "params": { "amplitude": 0.8 } }, "received_at": "…" }
A repeated tip with the same external_id returns 202 with "duplicate": true and the original id.
#Errors
Errors are JSON: { "error": "…" }.
| Status | Meaning |
|---|---|
400 | The body isn’t valid (the message says which field). |
401 | Missing, unknown or revoked key. |
403 | The key doesn’t have the scope this needs. |
404 | No such endpoint, or no such command for this key. |
413 | Body larger than 8 KB. |
429 | Over the rate limit; wait for Retry-After seconds. |
503 | Temporarily unavailable; try again shortly. |
#Delivery
Commands play while the Fluffer app is open on the user’s phone with Remote control on. Motion commands that arrive while it isn’t listening are dropped rather than played late (a stale “vibrate” should never fire minutes later). Tips and fuel sent during a brief disconnect are caught up when the app reconnects. Use GET /me to check a key, GET /status (scope device.status) to see whether the user’s app is listening right now, and GET /commands/{id} to see whether a command played.
#Examples
// Node 18+
const res = await fetch('https://api.fluffer.app/v1/pattern', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.FLUFFER_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'pulse', params: { strength: 0.7 } }),
});
console.log(res.status, await res.json());
# Python, with requests
python3 - <<'PY'
import os, requests
r = requests.post("https://api.fluffer.app/v1/vibrate",
headers={"Authorization": f"Bearer {os.environ['FLUFFER_KEY']}"},
json={"strength": 0.5, "duration_ms": 4000})
print(r.status_code, r.json())
PY