Motion script storage, autonomous playback control, and live status
This reference describes every route implemented by the FUG scripts and playback API groups, including request and response data, access scope, operational behavior, and common errors.
Related resources:
| Environment | Base URL |
|---|---|
| Production | https://fug-prd.feelme.com |
The OpenAPI schema is available at /api/openapi.json. Swagger UI is available at /api/docs.
Every public scripts and playback route requires a Device Connection Key:
Authorization: DCK <device_connection_key>
Requests with a JSON body also require:
Content-Type: application/json
The authenticated DCK establishes two scopes:
source_url visibility.A deployment without a configured store does not register either API group. All scripts and playback paths return 404 Not Found in that configuration.
| Method | Path | Auth | Success | Purpose |
|---|---|---|---|---|
POST |
/api/scripts |
DCK | 201 |
Upload script content inline. |
POST |
/api/scripts/from-url |
DCK | 201 |
Fetch and store a script from a public URL. |
GET |
/api/scripts |
DCK | 200 |
List scripts owned by the caller. |
GET |
/api/scripts/{script_id} |
DCK | 200 |
Read metadata for any existing script. |
DELETE |
/api/scripts/{script_id} |
DCK owner | 200 |
Delete an owned script. |
POST |
/api/playback/play |
DCK | 202 |
Start, resume, or switch playback. |
POST |
/api/playback/pause |
DCK | 202 |
Pause playback. |
POST |
/api/playback/seek |
DCK | 202 |
Move to an absolute media position. |
POST |
/api/playback/sync |
DCK | 202 |
Request drift correction. |
POST |
/api/playback/speed |
DCK | 202 |
Change playback speed. |
POST |
/api/playback/offset |
DCK | 202 |
Set an absolute timing offset. |
POST |
/api/playback/stop |
DCK | 202 |
Stop playback. |
GET |
/api/playback/status |
DCK | 200 stream |
Stream playback and device status over SSE. |
source_format |
Input structure |
|---|---|
funscript |
JSON object containing an actions array of { "at": milliseconds, "pos": 0..100 }. |
autoblow_csv |
Delimited rows in time_ms,position order. Comma, semicolon, and tab delimiters are detected. An optional header row is accepted. |
Format detection follows this precedence:
source_format from the request.content_type.{ selects Funscript; other content selects Autoblow CSV.Funscript processing applies the optional inverted flag and optional range scaling before normalization. inverted defaults to false; range defaults to 100; version is ignored.
Every parsed timeline is normalized as follows:
0..100.| Limit | Default | Failure |
|---|---|---|
| Script lifetime | 172,800 seconds (48 hours) | Expired scripts return 404. |
| Inline content | 5,000,000 characters | 413 |
| URL response body | 5,242,880 bytes (5 MiB) | 413 |
| URL fetch timeout | 10 seconds | 504 |
| Timeline duration | 14,400,000 ms (4 hours) | Later points are truncated. |
| Timeline points | 200,000 | 422 |
| Scripts per owner | 10 | 409 |
name |
256 UTF-8 bytes | 422 |
author |
256 UTF-8 bytes | 422 |
| Tag count | 16 | 422 |
| Total tag text | 1,024 UTF-8 bytes | 422 |
Scripts are immutable. A replacement uses delete followed by another upload. Playback refreshes the script storage lifetime.
ScriptMetadataResponse| Field | Type | Always present | Description |
|---|---|---|---|
script_id |
string | Yes | Server-generated 32-character hexadecimal identifier. |
source_format |
funscript \| autoblow_csv |
Yes | Parsed source format. |
point_count |
integer | Yes | Number of normalized timeline points. |
duration_ms |
integer | Yes | Timestamp of the last retained point. |
created_at |
ISO 8601 date-time | Yes | UTC creation time. |
updated_at |
ISO 8601 date-time | Yes | UTC metadata update time. |
name |
string or null | No | Optional display name. |
tags |
string array | No | Optional tags; defaults to an empty array. |
author |
string or null | No | Optional author metadata. |
source_url |
string or null | Owner responses only | Original URL for URL imports; null for inline uploads. Omitted from non-owner metadata responses. |
Example:
{
"script_id": "4df8eead6bad4eecb50b236bf49d1c09",
"source_format": "funscript",
"point_count": 820,
"duration_ms": 120000,
"created_at": "2026-08-18T10:15:30Z",
"updated_at": "2026-08-18T10:15:30Z",
"name": "Demo motion",
"tags": ["demo", "browser"],
"author": "Integration team",
"source_url": null
}
Domain errors use a string detail:
{
"detail": "timeline is empty after truncation"
}
FastAPI request validation errors use an array:
{
"detail": [
{
"type": "missing",
"loc": ["body", "content"],
"msg": "Field required",
"input": {}
}
]
}
POST /api/scriptsStores Funscript or Autoblow CSV content included in the JSON request. The route is suitable for a browser file picker, generated content, or content already held by an application backend.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
content |
string | Yes | — | Full script source. |
source_format |
enum or null | No | Auto-detect | funscript or autoblow_csv. |
content_type |
string or null | No | null |
Format hint such as application/json or text/csv. |
name |
string or null | No | null |
Display name, up to the configured byte limit. |
tags |
string array | No | [] |
Search or application metadata. |
author |
string or null | No | null |
Author metadata. |
max_duration_ms |
integer or null | No | Global limit | Per-upload truncation limit. The lower of this value and the global limit applies. |
curl -X POST https://fug-prd.feelme.com/api/scripts \
-H "Authorization: DCK $DCK" \
-H "Content-Type: application/json" \
-d '{
"content": "{\"actions\":[{\"at\":0,\"pos\":10},{\"at\":1000,\"pos\":90}]}",
"source_format": "funscript",
"name": "Two-point demo",
"tags": ["demo"],
"author": "Integration team"
}'
201 CreatedScriptMetadataResponse| Status | Condition |
|---|---|
400 |
Content cannot be parsed in the selected or detected format. |
409 |
Owner script quota is full. |
413 |
Inline character limit is exceeded. |
422 |
Point, duration, metadata, or tag limits fail. |
POST /api/scripts/from-urlFetches a script on the FUG server, parses the result, and stores the normalized timeline. Raw source content is discarded after parsing.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
url |
string | Yes | — | Public source URL. HTTPS is required by default. |
source_format |
enum or null | No | Auto-detect | funscript or autoblow_csv. |
name |
string or null | No | null |
Display name. |
tags |
string array | No | [] |
Search or application metadata. |
author |
string or null | No | null |
Author metadata. |
max_duration_ms |
integer or null | No | Global limit | Per-upload truncation limit. |
The fetcher rejects loopback, private, link-local, multicast, reserved, and cloud-metadata addresses. Redirects are followed manually, with validation on every hop and a maximum of three redirects. A HEAD pre-check and streamed byte cap protect the download size.
curl -X POST https://fug-prd.feelme.com/api/scripts/from-url \
-H "Authorization: DCK $DCK" \
-H "Content-Type: application/json" \
-d '{
"url": "https://cdn.example.com/motion/demo.funscript",
"name": "Remote demo",
"tags": ["remote"]
}'
201 CreatedScriptMetadataResponsesource_url: present for the owner.| Status | Condition |
|---|---|
400 |
URL policy blocks the address, or fetched content cannot be parsed. |
409 |
Owner script quota is full. |
413 |
Advertised or streamed response size exceeds the cap. |
422 |
Point, duration, metadata, or tag limits fail. |
502 |
Upstream transport, redirect, empty-body, or HTTP failure occurs. |
504 |
Remote fetch exceeds the configured timeout. |
GET /api/scriptsReturns metadata for scripts owned by the authenticated DCK. Results use newest-first order. Expired script records are removed from the owner index during listing.
curl https://fug-prd.feelme.com/api/scripts \
-H "Authorization: DCK $DCK"
200 OKScriptMetadataResponse[][
{
"script_id": "4df8eead6bad4eecb50b236bf49d1c09",
"source_format": "funscript",
"point_count": 820,
"duration_ms": 120000,
"created_at": "2026-08-18T10:15:30Z",
"updated_at": "2026-08-18T10:15:30Z",
"name": "Demo motion",
"tags": ["demo"],
"author": null,
"source_url": null
}
]
GET /api/scripts/{script_id}Returns metadata for an existing script. Any valid DCK can read metadata by ID. The owner receives source_url; a different DCK receives the same metadata with source_url omitted.
| Parameter | Type | Description |
|---|---|---|
script_id |
string | Server-generated script identifier. |
curl https://fug-prd.feelme.com/api/scripts/4df8eead6bad4eecb50b236bf49d1c09 \
-H "Authorization: DCK $DCK"
200 OKScriptMetadataResponse404 Not Found when the identifier is unknown or expiredDELETE /api/scripts/{script_id}Deletes the metadata, normalized timeline, and owner index entry. Only the creating DCK can delete the script.
curl -X DELETE https://fug-prd.feelme.com/api/scripts/4df8eead6bad4eecb50b236bf49d1c09 \
-H "Authorization: DCK $DCK"
200 OK{
"status": "deleted",
"script_id": "4df8eead6bad4eecb50b236bf49d1c09"
}
| Status | Condition |
|---|---|
403 |
Script exists but belongs to another DCK. |
404 |
Script is unknown or expired. |
Every playback control targets the device identified by the authenticated DCK. No playback request accepts a device identifier in the body.
Control routes update durable desired state or publish a drift-correction event and then return 202 Accepted. The player-worker applies the action asynchronously. Physical device execution is not confirmed by the HTTP acknowledgement.
PlaybackAccepted| Field | Type | Always present | Description |
|---|---|---|---|
status |
string | Yes | Acceptance result such as started, resumed, switching, rejected, or accepted. |
request_id |
string | Yes | Server-generated correlation identifier. A directly applied control normally uses this value as SSE origin; reconciliation can use system. |
detail |
string or null | No | Additional context, mainly for a rejected duplicate play request. |
{
"status": "accepted",
"request_id": "f9e45c14631c4f90939c16aebd0d65bd",
"detail": null
}
The status stream is the source of observable playback results. Control requests do not wait for a connected device, active player, or physical command acknowledgement.
| Setting | Default | Behavior |
|---|---|---|
| Speed range | 0.5..2.0 |
Play and speed values are clamped server-side. |
| Pause timeout | 900 seconds | A long paused session stops automatically. |
| Sync drift threshold | 500 ms | Smaller sync corrections are ignored. |
| Sync minimum interval | 5 seconds | More frequent sync corrections are ignored. |
| SSE keepalive | 15 seconds | Comment frames keep idle connections open. |
| SSE streams per API instance | 100 | Additional streams receive 429. |
| Profile | Default | Position mapping | Speed ratio |
|---|---|---|---|
keon_wifi |
Yes | Inverted, clamped to device range 0..99 |
180 |
keon2 |
No | Non-inverted, clamped to device range 0..99 |
250 |
An unknown profile for a new session is detected by the player-worker after HTTP acceptance and produces an SSE error frame with detail: "unknown profile". A seamless switch keeps the active session profile because the switch control does not carry a replacement profile.
POST /api/playback/playStarts a new playback session, resumes a paused session, or switches an active device to another script.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
script_id |
string | Yes | — | Existing script identifier. Ownership is not required. |
start_ms |
integer, minimum 0 |
No | 0 |
Absolute starting position. |
speed_scale |
number | No | 1.0 |
Initial playback rate, clamped to the configured range. |
profile |
string or null | No | keon_wifi |
Device motion profile for a newly started session. Supported values are keon_wifi and keon2; a seamless switch retains the active profile. |
curl -X POST https://fug-prd.feelme.com/api/playback/play \
-H "Authorization: DCK $DCK" \
-H "Content-Type: application/json" \
-d '{
"script_id": "4df8eead6bad4eecb50b236bf49d1c09",
"start_ms": 0,
"speed_scale": 1.0,
"profile": "keon_wifi"
}'
status |
Condition | Processing behavior |
|---|---|---|
started |
No live player owns the device. | Desired state becomes playing; a start request enters the worker queue. |
resumed |
The same script exists in a paused live session. | A play control resumes the current owner. |
switching |
A different script is requested during a live session. | The current owner loads the new timeline without releasing the device lease. |
rejected |
The same script is already playing. | No restart occurs; detail contains already playing this script, and a rejection frame is published. |
Example:
{
"status": "started",
"request_id": "f9e45c14631c4f90939c16aebd0d65bd",
"detail": null
}
| Status | Condition |
|---|---|
404 |
script_id does not exist at request time. |
422 |
script_id is missing, start_ms is negative, or another field has an invalid JSON type. |
A script can expire between HTTP validation and worker loading. That race produces a later SSE frame with state error and detail script not found.
POST /api/playback/pauseFreezes the playback clock at the current position and sets durable desired state to paused.
No request body is defined or required.
curl -X POST https://fug-prd.feelme.com/api/playback/pause \
-H "Authorization: DCK $DCK"
202 AcceptedPlaybackAccepted with status: "accepted"state: "paused"The route does not reject a missing active player. Desired state is still recorded, and a later status subscription can be primed from that state.
POST /api/playback/seekMoves playback to an absolute media position. Seek represents explicit interface intent and updates durable desired state.
| Field | Type | Required | Description |
|---|---|---|---|
position_ms |
integer, minimum 0 |
Yes | Absolute media position in milliseconds. |
curl -X POST https://fug-prd.feelme.com/api/playback/seek \
-H "Authorization: DCK $DCK" \
-H "Content-Type: application/json" \
-d '{ "position_ms": 12000 }'
202 AcceptedPlaybackAccepted with status: "accepted"422 for a missing or negative positionThe route accepts a position beyond the script duration. The player state and end-of-timeline behavior remain observable through SSE.
POST /api/playback/syncRequests drift correction against an external media position. Sync is transient and does not change durable desired state.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
position_ms |
integer, minimum 0 |
Yes | — | External media position in milliseconds. |
client_ts |
number or null | No | null |
Reserved client timestamp. The current worker accepts the value without transport-delay compensation because no shared clock exists. |
curl -X POST https://fug-prd.feelme.com/api/playback/sync \
-H "Authorization: DCK $DCK" \
-H "Content-Type: application/json" \
-d '{ "position_ms": 12000, "client_ts": 1787048100.25 }'
202 AcceptedPlaybackAccepted with status: "accepted"422 for a missing or negative positionThe worker applies sync only while playing, only when absolute drift exceeds the configured threshold, and only after the configured minimum interval. An applied correction emits a playback frame with detail: "sync". An ignored correction emits no dedicated result frame.
POST /api/playback/speedChanges playback rate while preserving the current media position.
| Field | Type | Required | Description |
|---|---|---|---|
speed |
number | Yes | Requested rate. The API clamps the value to the configured range, currently 0.5..2.0. |
curl -X POST https://fug-prd.feelme.com/api/playback/speed \
-H "Authorization: DCK $DCK" \
-H "Content-Type: application/json" \
-d '{ "speed": 1.5 }'
202 AcceptedPlaybackAccepted with status: "accepted"speed_scale in a playback SSE frame422 when speed is missing or is not a JSON numberExample clamping: a request containing 5.0 records and applies 2.0 under the current defaults.
POST /api/playback/offsetSets an absolute signed timing offset. Repeating the same value is idempotent. A changed value shifts the effective media position by the offset delta.
| Field | Type | Required | Description |
|---|---|---|---|
offset_ms |
integer | Yes | Absolute timing offset in milliseconds. Negative and positive values are accepted. |
curl -X POST https://fug-prd.feelme.com/api/playback/offset \
-H "Authorization: DCK $DCK" \
-H "Content-Type: application/json" \
-d '{ "offset_ms": -250 }'
202 AcceptedPlaybackAccepted with status: "accepted"offset_ms in a playback SSE frame422 when offset_ms is missing or is not an integerPOST /api/playback/stopSets desired state to stopped and requests terminal playback cleanup. The player sends a best-effort device pause, publishes a final status frame, releases the device lease, and leaves the device room.
No request body is defined or required.
curl -X POST https://fug-prd.feelme.com/api/playback/stop \
-H "Authorization: DCK $DCK"
202 AcceptedPlaybackAccepted with status: "accepted"state: "stopped"No automatic resume follows a terminal state. Another play request is required for a new session.
GET /api/playback/statusOpens a long-lived Server-Sent Events stream for the authenticated device. The stream carries playback lifecycle frames and raw device status forwarded by the player-worker.
GET /api/playback/status HTTP/1.1
Host: fug-prd.feelme.com
Authorization: DCK <device_connection_key>
Accept: text/event-stream
Browser integrations must use streaming fetch rather than native EventSource, because EventSource cannot attach the DCK header.
200 OKtext/event-stream429 Too Many RequestsEach application message uses an SSE data: field containing one JSON object:
data: {"type":"playback","device_key":"<device_connection_key>","state":"playing","position_ms":4200,"index":12,"point_count":820,"speed_scale":1.0,"offset_ms":0,"origin":"system","script_id":"4df8eead6bad4eecb50b236bf49d1c09","detail":null}
| Field | Type | Description |
|---|---|---|
type |
string | Always playback. |
device_key |
string | Authenticated target DCK. The field should be treated as sensitive. |
script_id |
string or null | Current script identifier. |
state |
enum | idle, playing, paused, stopped, ended, error, or lease_lost. |
position_ms |
integer | Current absolute media position. |
index |
integer | Current normalized timeline index. |
point_count |
integer | Total timeline points. A primed or synthetic event can contain 0. |
speed_scale |
number | Current playback rate. |
offset_ms |
integer | Current absolute timing offset. |
origin |
string | Related control request_id or system. |
detail |
string or null | State context such as sync, unknown profile, or a terminal reason. |
| State | Meaning |
|---|---|
idle |
No active playback state. |
playing |
Timeline clock is advancing and due device commands can be emitted. |
paused |
Timeline clock is frozen. |
stopped |
Explicit stop or orphan reconciliation ended the session. |
ended |
The final timeline point completed. |
error |
A terminal processing, socket, profile, or repeated emit failure occurred. |
lease_lost |
Another worker became authoritative; the old player stopped immediately. |
| Field | Type | Description |
|---|---|---|
type |
string | Always device_status. |
payload |
object | Raw device status received from the relay. The payload schema depends on the connected device and firmware. |
received_at |
number | Gateway receipt time as Unix epoch seconds. |
{
"type": "device_status",
"payload": {
"device_connection_key": "<device_connection_key>",
"event_type": "<device_event_type>",
"data": {}
},
"received_at": 1787048130.125
}
The stream should reconnect with backoff after transient failures. A fresh connection receives primed state, which removes any requirement for sticky API sessions.
| Status | Example detail |
Cause |
|---|---|---|
401 |
Unauthorized: Missing authorization header |
Header is absent. |
401 |
Unauthorized: Invalid auth scheme, expected 'DCK <device_connection_key>' |
Scheme is not DCK. |
401 |
Unauthorized: Missing DCK value |
Header contains no token. |
401 |
Unauthorized: Invalid or missing device connection key |
Upstream verification rejects the DCK. |
422 |
Unprocessable: Device connection key cannot be processed |
Verification service times out, fails, or returns invalid JSON. |
| Status | Meaning | Retry guidance |
|---|---|---|
400 Bad Request |
Script parse failure or URL policy rejection. | Correct the source or URL before another request. |
403 Forbidden |
Script deletion attempted by a non-owner. | No retry under the same DCK. |
404 Not Found |
Script missing or expired; feature routes absent when the store is disabled. | Refresh metadata and verify deployment configuration. |
409 Conflict |
Per-owner script quota reached. | Delete an unused owned script before upload. |
413 Content Too Large |
Inline or remote source exceeds a configured cap. | Reduce source size. |
422 Unprocessable Content |
Pydantic validation or timeline/metadata limits fail. | Correct fields or limits. |
429 Too Many Requests |
SSE capacity reached on the API instance. | Reconnect after a delay. |
500 Internal Server Error |
Unexpected application or store failure. | Record request context and apply bounded retry policy. |
502 Bad Gateway |
Remote script fetch fails. | Verify remote host and response. |
504 Gateway Timeout |
Remote script fetch exceeds timeout. | Retry later or use inline upload. |
HTTP 202 can precede an asynchronous error. Examples include:
unknown profilescript not found after an expiration racedevice busy during lease acquisitionSuch failures appear as playback SSE frames, usually with state: "error" or state: "lease_lost" and a descriptive detail.
A network timeout after play creates ambiguous delivery. The status stream should be checked before another play request. Immediate blind retries can produce an unwanted resume, switch, or duplicate rejection.
| Rule | Behavior |
|---|---|
| Script identity | A UUID4 hexadecimal ID addresses each script globally. |
| Ownership | The upload DCK owns the script. Owner identity is stored but never returned. |
| Listing | Only the owner index is listed. |
| Metadata sharing | Any valid DCK can read metadata by ID. |
| Source URL privacy | Only the owner receives source_url. |
| Playback sharing | Any valid DCK can play an existing script on the device identified by that DCK. |
| Mutation | Script content cannot be edited or replaced in place. |
| Expiration | Script records expire after the configured TTL; playback refreshes the TTL. |
| Device exclusivity | A per-device lease permits one authoritative player across the worker fleet. |
| Terminal behavior | stopped, ended, error, and lease_lost require another explicit play request. |
| Status delivery | REST controls acknowledge intent; SSE reports observable state. |
GET /api/playback/status with the DCK header.POST /api/scripts or POST /api/scripts/from-url.script_id.POST /api/playback/play with that identifier.request_id and compare later SSE origin, state, and control values.The live browser example implements this sequence without a framework or build step.