New: schedule straight from DaVinci Resolve with the Reelport plugin →

API and MCP

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/v1 in production. Locally it is http://localhost:8124/api/v1 with the dev server from .claude/launch.json, or http://localhost:8000/api/v1 with a plain php artisan serve. The old production address https://reelport.io/api/v1 keeps 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/token and /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}
  1. Show user_code and open verification_uri_complete. The user signs in, checks the code, picks a team and clicks Connect Reelport, or Decline.
  2. Poll every interval seconds:
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

  1. Sign in to reelport.io and open Settings › API tokens.
  2. 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.
  3. Create token. The token (rp_ followed by 40 characters) is shown once.
  4. 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 of none, trial, creator, pro or agency.
  • plan.status: one of active, trialing, past_due or canceled.
  • Presets: "*" means every preset. A null limit 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"}}
  1. PUT the raw file bytes to url with exactly headers, before expires_at (2 hours).
  2. 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_at in ISO 8601 with an offset, or "next_free_slot": true together with "timezone": "Europe/Berlin". With neither, the post takes the next free slot. "publish_now": true publishes right away, replacing the plugin's direct publishing. "draft": true saves the post without scheduling it.
  • Rules: the same rules as the composer apply.
    • TikTok: privacy_level is required, has no default and must be one of the channel's privacy_level_options. Interactions are off unless you set them true; ones the creator disabled are refused. Branded content can't be SELF_ONLY. The video must fit max_video_post_duration_sec.
    • YouTube: title (up to 100 characters), privacy and made_for_kids are required. Optional: tags (up to 30, 30 characters each), category_id (YouTube category id, default 22), notify_subscribers (default true).
    • Instagram: share_to_feed is 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 video title.
    • Any platform: caption in 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.
  • 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), failed or cancelled.
  • Each target's status: pending, queued, publishing, published (with url), failed (with error, in plain words) or cancelled.

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 200 and updates the row. channel defaults to stable.
  • 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:publish is 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: 401 for an unknown token, 403 without the ability, 422 for an invalid body (the url must be https and sha256 must be 64 lowercase hex).
  • Signing: each release is signed when /releases/latest serves it, with RELEASE_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