For the Reelport Resolve plugin, its Claude extension and your own scripts: list channels, upload renders, schedule posts and read their results.
- Base URL:
https://api.reelport.io/v1in production. Locally it ishttp://localhost:8124/api/v1with the dev server from.claude/launch.json, orhttp://localhost:8000/api/v1with a plainphp artisan serve. The old production addresshttps://reelport.io/api/v1keeps working for existing clients. - Auth:
Authorization: Bearer rp_…, a personal API token. - Format: JSON in and out. Send
Accept: application/json. - Ids: every id (users, teams, channels, media, posts) is a UUID string, for example
019a5f2e-8d22-7f01-a3c4-5b6e7f801212. Treat it as opaque. - No token needed:
/device/code,/device/tokenand/releases/latest. - MCP: the same tools over MCP (Streamable HTTP) at
https://reelport.io/mcp, with the same token. Tools:list-channels,next-slots,schedule-post,get-post.
Signing in
Device flow (Reelport plugin and Claude)
This is the OAuth 2.0 device authorization grant (RFC 8628). The plugin and Claude never see a password.
POST /v1/device/code
{"client_id": "reelport-plugin"} // or "reelport-claude"
200 {"device_code": "…48 chars…", "user_code": "BCDF-GHJK",
"verification_uri": "https://reelport.io/device",
"verification_uri_complete": "https://reelport.io/device?code=BCDF-GHJK",
"interval": 5, "expires_in": 900}
- Show
user_codeand openverification_uri_complete. The user signs in, checks the code, picks a team and clicks Connect Reelport, or Decline. - Poll every
intervalseconds:
POST /v1/device/token
{"device_code": "…"}
200 {"access_token": "rp_…", "token_type": "Bearer", "team_id": "019a5f2e-7c10-7b3a-9e41-2f6d8c0a1b03"}
400 {"error": "authorization_pending" | "slow_down" | "expired_token" | "access_denied", "error_description": "…"}
slow_down: add 5 seconds to your polling interval.- Codes: a code lives 15 minutes, and its token can be collected once.
- Revoking: the token appears under Settings › API tokens, marked "Signed in from Reelport plugin", where the user can revoke it.
Personal tokens
- Sign in to reelport.io and open Settings › API tokens.
- Name the token and tick its abilities:
channels:read,media:write,posts:read,posts:write,brand:read,brand:write(all of them by default). Device-flow tokens from the plugin have every ability. - Create token. The token (
rp_followed by 40 characters) is shown once. - A token acts as its user inside one team. Revoke stops it immediately.
Account, plan and entitlements: GET /me
{"data": {
"user": {"id": "019a5f2e-7c10-7b3a-9e41-2f6d8c0a1b01", "name": "Flo", "email": "flo@example.com"},
"team": {"id": "019a5f2e-7c10-7b3a-9e41-2f6d8c0a1b03", "name": "Studio Nord", "slug": "studio-nord"},
"plan": {"slug": "creator", "name": "Creator", "status": "active",
"renews_at": "2026-12-07T10:00:00+00:00", "ends_at": null, "trial_ends_at": null,
"caps": {"seats": 1, "accounts": 3, "posts_per_month": 60, "queue": 30, "storage_gb": 20, "upload_mb": 1024, "api_per_minute": 60},
"usage": {"seats": 1, "accounts": 2, "posts_per_month": 14, "queue": 5, "storage_gb": 3.2}},
"entitlements": {
"features": ["captions", "cutting", "finishing", "brand_kit", "publish", "styles", "schedule"],
"presets": {"captions": ["karaoke_simple", "…"], "titles": ["kinetic", "lower_third", "counter", "callout"], "looks": ["film", "orange_teal", "natural", "black_white"]},
"limits": {"scheduled_posts_per_month": null, "variations_per_reel": 1}
},
"token": {"name": "Reelport plugin", "client": "reelport-plugin", "abilities": ["channels:read", "media:write", "posts:read", "posts:write"]}
}}
plan.slug: one ofnone,trial,creator,prooragency.plan.status: one ofactive,trialing,past_dueorcanceled.- Presets:
"*"means every preset. Anulllimit means no limit. - What the plugin shows: it hides any feature or preset that isn't listed.
- Where the mapping lives:
config/entitlements.php, with the plugin's 69 caption, 15 title and 15 look ids. The default tiers, which the owner still has to confirm:
| Tier | Features | Presets | Variations per reel |
|---|---|---|---|
| none | none | none | 0 |
| trial, pro, agency | everything, including director and variations |
all | 3 (trial), 5 (pro), 10 (agency) |
| creator | everything except director and variations |
12 captions, the 4 base titles, 4 looks | 1 |
Errors
Every error has the same shape, with a message you can show the user as it is:
{"error": {"code": "validation_failed", "message": "Choose who can watch this video.",
"fields": {"options.tiktok.privacy_level": ["The privacy level field is required."]}}}
| Status | code |
When |
|---|---|---|
| 401 | unauthenticated |
The token is missing, wrong or revoked. |
| 402 | no_plan |
The team has no active plan (trial over). Drafts still work. Brand kit writes also need a plan with the brand_kit feature. |
| 403 | forbidden |
The token lacks the ability the endpoint needs, or the user's team role is too low (brand kit writes: owners and admins). |
| 404 | not_found |
The item doesn't exist or belongs to another team. |
| 422 | validation_failed |
See fields. Keys use the request's own names, e.g. options.youtube.made_for_kids or channel_ids.1. |
| 422 | validation_failed |
Also when a plan cap is reached: fields.plan (posts a month, scheduled at once), fields.file / fields.size (largest upload, storage). The message says which cap and to upgrade under Billing. |
| 429 | rate_limited |
Over the plan's API requests a minute (plan.caps.api_per_minute: trial 30, Creator 60, Pro 120, Agency 300), or 60 uploads an hour, per token. |
| 5xx | server_error |
Try again later. |
Channels: GET /channels (channels:read)
/accounts is an alias. TikTok channels include the creator's live posting options. Offer only
those privacy levels and don't preselect one. Pass ?include=none to skip the TikTok lookup.
{"data": [
{"id": "019a5f2e-8d22-7f01-a3c4-5b6e7f801212", "platform": "tiktok", "name": "Flo", "handle": "flo.drone", "status": "active", "takes": ["video"],
"tiktok": {"nickname": "Flo", "privacy_level_options": ["PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "SELF_ONLY"],
"comment_disabled": false, "duet_disabled": true, "stitch_disabled": false, "max_video_post_duration_sec": 600}},
{"id": "019a5f2e-8d22-7f01-a3c4-5b6e7f801313", "platform": "youtube", "name": "Studio Nord", "handle": "@studionord", "status": "active", "takes": ["video"]},
{"id": "019a5f2e-8d22-7f01-a3c4-5b6e7f801414", "platform": "instagram", "name": "studionord", "handle": "studionord", "status": "active", "takes": ["video", "image"]},
{"id": "019a5f2e-8d22-7f01-a3c4-5b6e7f801515", "platform": "facebook", "name": "Studio Nord", "handle": null, "status": "needs_reconnect", "takes": ["video", "image"]}
]}
status: needs_reconnect means the user has to reconnect that channel in reelport.io first.
Media (media:write)
Videos must be MP4 or MOV, up to 1 GB and 10 minutes. Photos can be JPEG, PNG or WebP up to 8 MB; they are stored as JPEG and only Instagram and Facebook take them.
Direct upload (recommended for renders)
The file goes straight to storage (S3, on Hetzner Object Storage in production) without passing through reelport.io.
POST /v1/media/uploads
{"filename": "drone-variation-2.mp4", "size": 312456789, "content_type": "video/mp4"}
201 {"data": {"upload_id": "9b2f…", "method": "PUT", "url": "https://…signed…",
"headers": {"Content-Type": "video/mp4"}, "expires_at": "2026-11-07T19:30:00+00:00"}}
PUTthe raw file bytes tourlwith exactlyheaders, beforeexpires_at(2 hours).- Then:
POST /v1/media/uploads/9b2f…/complete
201 {"data": {"id": "019a5f31-0e5d-7c2a-8b19-4d7e6f204141", "kind": "video", "name": "drone-variation-2.mp4", "size": 312456789,
"duration_seconds": 28.4, "width": 1080, "height": 1920, "created_at": "…"}}
If nothing has arrived yet, complete answers 422.
Plain upload
POST /v1/media as multipart/form-data with a file field. It answers 201 with the same
data as above. This is fine for small files.
GET /v1/media/{id} returns the same shape.
Posts
POST /posts (posts:write)
{
"media_id": "019a5f31-0e5d-7c2a-8b19-4d7e6f204141",
"channel_ids": ["019a5f2e-8d22-7f01-a3c4-5b6e7f801212", "019a5f2e-8d22-7f01-a3c4-5b6e7f801313", "019a5f2e-8d22-7f01-a3c4-5b6e7f801414"],
"caption": "Sunrise over the harbour #drone",
"scheduled_at": "2026-11-10T18:00:00+01:00",
"options": {
"tiktok": {"privacy_level": "PUBLIC_TO_EVERYONE", "allow_comment": true, "allow_duet": false, "allow_stitch": false,
"brand_organic": false, "brand_content": false},
"youtube": {"title": "Sunrise over the harbour", "privacy": "public", "made_for_kids": false},
"instagram": {"share_to_feed": true}
}
}
- Time: give
scheduled_atin ISO 8601 with an offset, or"next_free_slot": truetogether with"timezone": "Europe/Berlin". With neither, the post takes the next free slot."publish_now": truepublishes right away, replacing the plugin's direct publishing."draft": truesaves the post without scheduling it. - Rules: the same rules as the composer apply.
- TikTok:
privacy_levelis required, has no default and must be one of the channel'sprivacy_level_options. Interactions are off unless you set them true; ones the creator disabled are refused. Branded content can't beSELF_ONLY. The video must fitmax_video_post_duration_sec. - YouTube:
title(up to 100 characters),privacyandmade_for_kidsare required. Optional:tags(up to 30, 30 characters each),category_id(YouTube category id, default22),notify_subscribers(default true). - Instagram:
share_to_feedis optional and defaults to true. Photos must be between 4:5 and 1.91:1. Optional:collaborators(up to 3 usernames),cover_ms(reel cover frame). - TikTok also takes
cover_ms(cover frame); Facebook takes a videotitle. - Any platform:
captionin its options replaces the post caption for that channel only. Limits: Instagram and TikTok 2,200 characters, YouTube 5,000, Facebook 63,206. - Photos: sent to TikTok or YouTube, they are refused.
- TikTok:
- Response (
201):
{"data": {"id": "019a5f33-4a91-7e60-b2d3-9c8f1e508888", "status": "scheduled", "scheduled_at": "2026-11-10T17:00:00+00:00", "caption": "…", "media_id": "019a5f31-0e5d-7c2a-8b19-4d7e6f204141",
"url": "https://reelport.io/studio-nord/posts/019a5f33-4a91-7e60-b2d3-9c8f1e508888/edit",
"targets": [{"channel_id": "019a5f2e-8d22-7f01-a3c4-5b6e7f801212", "platform": "tiktok", "status": "pending", "url": null, "error": null, "published_at": null}]}}
GET /posts/{id} (posts:read)
This returns the same shape.
- Post
status:draft,scheduled,publishing,published,partial(some channels failed),failedorcancelled. - Each target's
status:pending,queued,publishing,published(withurl),failed(witherror, in plain words) orcancelled.
Slots: GET /slots (posts:read)
These are the next free queue slots: the daily times 09:00, 12:30 and 18:00 in your timezone, minus the ones that already have a post within 30 minutes. Use them to spread variations over the coming days.
GET /v1/slots?count=3&timezone=Europe/Berlin&channel=019a5f2e-8d22-7f01-a3c4-5b6e7f801212
200 {"data": ["2026-11-10T18:00:00+01:00", "2026-11-11T09:00:00+01:00", "2026-11-11T12:30:00+01:00"]}
Brand kit
One brand kit per team (the token's team): the logo watermark, colours and fonts the plugin applies.
The plugin and reelport.io › Brand kit (reelport.io/brand-kit) edit the
same kit, so sync both ways by comparing updated_at (or the logo's sha256).
GET /brand-kit (brand:read)
Always works, also without a plan. Before anything is saved you get the defaults and updated_at: null.
{"data": {
"logo_position": "top_right",
"logo_size": 0.12,
"logo_opacity": 0.9,
"brand_color": "#ff6a2b",
"accent_color": "#e9f53b",
"text_color": "#ffffff",
"font_captions": "Bebas Neue",
"font_display": "Montserrat",
"use_brand": true,
"watermark_auto": true,
"logo": {"url": "https://…signed, valid 30 minutes…", "sha256": "9f2c…64 hex…", "mime": "image/png",
"size": 48213, "width": 1200, "height": 400},
"updated_at": "2026-11-08T14:02:11+00:00"
}}
| Field | Values | Default |
|---|---|---|
logo_position |
top_left, top_right, bottom_left, bottom_right, center |
top_right |
logo_size |
0.03 to 0.5, the logo's width as a share of the video width | 0.12 |
logo_opacity |
0.1 to 1 | 0.9 |
brand_color, accent_color, text_color |
"#rrggbb" (stored lowercase) or null |
null |
font_captions, font_display |
font family name, up to 100 characters, or null |
null |
use_brand, watermark_auto |
true / false |
true |
logo |
null, or the object above. url is short-lived: download it right away, don't store it. |
null |
updated_at |
server time of the last change, ISO 8601; null if never saved |
null |
PUT /brand-kit (brand:write)
Send any subset of the scalar fields (not logo); the rest stay as they are. null or "" clears a
colour or font. Answers 200 with the whole kit, or 422 with fields for bad values. Every write
bumps updated_at.
PUT /v1/brand-kit
{"logo_position": "bottom_right", "logo_size": 0.15, "brand_color": "#FF6A2B", "font_captions": null}
POST /brand-kit/logo (brand:write)
Multipart, field file: a PNG or JPEG up to 5 MB (a PNG with transparency is the norm). Replaces any
previous logo. The server works out sha256, size, width and height. Answers 200 with the whole kit.
DELETE /brand-kit/logo (brand:write)
Removes the logo. Answers 200 with the whole kit (logo: null).
Writes need a plan with the brand_kit feature (402 no_plan otherwise) and a user who is an
owner or admin of the team (403 forbidden otherwise). Reads work for every member.
Plugin updates: GET /releases/latest (no token)
GET /v1/releases/latest?product=reelport-plugin&machine=<machine hash>&channel=stable
200 {"data": {"version": "2.0.0", "url": "https://…/Reelport-2.0.0.zip", "sha256": "…64 hex…", "size": 41234567,
"notes": "…", "published_at": "…", "token": "<base64 json>.<base64 Ed25519 signature>"}}
- Token: it uses the scheme the updater already verifies. The payload is
{type: "update", kid, product: "de.pixelandprocess.reelport", machine, version, url, sha256, size, notes, issued_at}, and the signature covers those exact JSON bytes. - Signing key: the owner's Ed25519 key goes in
RELEASE_SIGNING_KEY. Until it is set, the endpoint answers 503, and with no release published it answers 404. - Publishing:
php artisan releases:publish reelport-plugin 2.0.0 <https url> <sha256> --size= --notes=.
Publishing a release from CI: POST /v1/admin/releases (releases:publish)
This does the same as artisan releases:publish. The Reelport GitHub Action calls it on every tag.
POST /v1/admin/releases
Authorization: Bearer rp_… (a release token, see below)
{"product": "reelport-plugin", "version": "2.1.0", "channel": "stable",
"url": "https://github.com/Pixel-Process-UG/reelport/releases/download/v2.1.0/Reelport-2.1.0.zip",
"sha256": "…64 hex…", "size": 41234567, "notes": "…"}
201 {"data": {"id": "…", "product": "reelport-plugin", "channel": "stable", "version": "2.1.0", "url": "…",
"sha256": "…", "size": 41234567, "notes": "…", "published_at": "…"}}
- Republishing: the same product, channel and version again answers
200and updates the row.channeldefaults tostable. - Token: in reelport.io, go to Admin › Releases › Create release token. The token is shown
once; store it as the repository secret
REELPORT_RELEASE_TOKEN. - What the token can do: only this endpoint.
releases:publishis never implied by a full-access token, and it stops working when its owner is no longer an admin. Revoke it under Settings › API tokens. - Errors:
401for an unknown token,403without the ability,422for an invalid body (the url must be https and sha256 must be 64 lowercase hex). - Signing: each release is signed when
/releases/latestserves it, withRELEASE_SIGNING_KEY/RELEASE_SIGNING_KID.
Example: three variations, three days
TOKEN=rp_…; API=https://api.reelport.io/v1
curl -s -H "Authorization: Bearer $TOKEN" "$API/slots?count=3&timezone=Europe/Berlin"
# for each render: POST /media/uploads → PUT the file → POST …/complete → POST /posts with one slot each