REST API
Tesselith API: Getting Started Guide
Updated
Authenticate, create a draft, schedule or queue it across networks, upload media and handle errors, limits and retries with the Tesselith REST API.
The Tesselith API lets your own code do what the composer does: create posts for any connected channel, schedule them for an exact time or add them to a channel's posting queue, upload images and video, and read how they performed. It's a JSON REST API at https://api.tesselith.com/v1. It goes through the same service layer as the app, so validation, permissions, plan limits and the audit log behave the same whether a post comes from the dashboard, a script or an AI assistant.
Quick start: your first scheduled post in 4 calls
If you just want to see it work, this is the whole flow. Each step is explained in detail further down.
export TESSELITH_KEY="tsl_live_…" # Settings → Developers → Create key
API=https://api.tesselith.com/v1
# 1. Find the channel ids you can post to
curl -s "$API/channels" -H "Authorization: Bearer $TESSELITH_KEY"
# 2. Create a draft for one or more channels
curl -s -X POST "$API/posts" \
-H "Authorization: Bearer $TESSELITH_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: launch-post-1" \
-d '{"body":"We just shipped dark mode 🌙","channel_ids":["CHANNEL_ID"]}'
# 3a. Schedule it for an exact time…
curl -s -X POST "$API/posts/POST_ID/schedule" \
-H "Authorization: Bearer $TESSELITH_KEY" \
-H "Content-Type: application/json" \
-d '{"scheduled_at":"2026-11-03T09:30:00-05:00"}'
# 3b. …or add it to the queue (next free slot on every channel)
curl -s -X POST "$API/posts/POST_ID/queue" \
-H "Authorization: Bearer $TESSELITH_KEY" \
-H "Content-Type: application/json" -d '{}'Authentication and API keys
Every request needs a bearer token in the Authorization header. Tokens are API keys that start with tsl_live_ followed by 40 random characters. Workspace owners and admins create them under Settings → Developers → Create key. You choose a name, the access level and an optional expiry (30 days, 90 days, 1 year, or never). The full key is shown exactly once. Tesselith stores only a SHA-256 hash, so a lost key can't be recovered, only rotated.
Scopes
A key can have full access, read-only access, or a custom set of scopes. The scopes are the same permissions the app's roles use:
| Scope | Allows |
|---|---|
channel:read | List connected channels |
post:read | List and read posts, queues and posting schedules |
post:create | Create drafts |
post:update | Edit posts, move them back to drafts, replace posting schedules |
post:publish | Schedule posts and add them to the queue |
post:delete | Delete posts |
media:upload | Upload images and video |
analytics:read | Read per-post metrics |
webhook:manage | Create, list and delete webhook endpoints |
A key can never do more than the person who created it. Effective access is the key's scopes intersected with the creator's role at the moment of each request. Demote the creator from admin to viewer and their keys immediately lose write access. Remove them from the workspace and their keys stop working. This is deliberate: a key is a delegation, not a separate identity that outlives the person who made it.
Rotating and revoking keys
Rotate issues a new secret with the same name, scopes and expiry and kills the old secret at once. Deploy the new key first if you need zero downtime, then rotate. Revoke ends a key permanently. Both take effect on the next request. Every call made with a key is written to the workspace audit log with the key's id, and the key list shows when each key was last used.
Channels: where posts can go
GET /channels returns every connected social account in the workspace with its id, platform, display name, handle, avatar and status. A status other than active means the network's authorization has expired and someone needs to reconnect it in the app. Posts targeting it will fail until they do, which is also what the channel.reconnect_required webhook tells you.
{
"data": [
{
"id": "3qY0yJk1n2Lr8sVd7a9Bc",
"platform": "instagram",
"name": "Acme Coffee",
"handle": "acmecoffee",
"avatar_url": "https://…",
"status": "active"
}
]
}Creating and editing posts
A post is created as a draft with POST /posts. It has a body, the channel_ids it targets, optional media_ids, an optional IANA timezone, and an optional thread of follow-up replies to publish under it. One post can target many channels: the same draft goes to Instagram, Threads, Bluesky and the rest.
const res = await fetch("https://api.tesselith.com/v1/posts", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TESSELITH_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({
body: "Our autumn menu is here. Pumpkin cold brew is back.",
channel_ids: [instagramId, threadsId, blueskyId],
media_ids: [photoId],
timezone: "America/New_York",
}),
})
if (!res.ok) throw new Error((await res.json()).code)
const post = await res.json() // status: "draft"PATCH /posts/{id} edits a post. Only the fields you send change, but channel_ids and thread replace the whole list when present. Use overrides to give a channel its own fields (for example a Reddit title or a Pinterest board), keyed by channel id. GET /posts lists posts newest first with optional status and channel_id filters, and GET /posts/{id} returns one.
Follow-up threads
To publish a reply chain under a post (a Bluesky or Threads thread, or a first comment), send thread items with a channel_id, body, sequence and optional delay_minutes. They publish in sequence order after the main post goes out on that channel.
Scheduling: exact time or queue
A draft becomes scheduled in one of two ways, and they mirror the two buttons in the composer.
- `POST /posts/{id}/schedule` with
scheduled_atpins the post to an exact moment. Always include an offset orZ;2026-11-03T09:30:00-05:00is unambiguous, while a bare2026-11-03T09:30:00is rejected. - `POST /posts/{id}/queue` gives the post the next free slot from its channels' weekly posting schedules.
position: "last"(the default) takes the next free slot;position: "next"jumps the line and moves queued posts back one slot. A multi-channel post waits for a time that's free on every one of its channels, so it lands everywhere together. The chosen time comes back inscheduled_at. - `POST /posts/{id}/unschedule` moves a scheduled post back to drafts.
Both scheduling calls need the post:publish scope. That's stricter than editing, so you can give an integration permission to prepare drafts without letting it publish anything. If a channel has no free slot, the queue call returns 422 with code NO_QUEUE_SLOT. Add times with PUT /channels/{id}/schedule or schedule for an exact time instead.
Reading and changing the queue
GET /queue?channel_id=… returns a channel's upcoming items for the next two weeks in time order: scheduled posts and the free slot times between them. GET /channels/{id}/schedule returns the weekly slots and the workspace timezone. PUT /channels/{id}/schedule replaces them with a list of {day_of_week, time} pairs (0 is Sunday, time is HH:mm local), up to 168 a week. Queued posts re-flow into the new slots. Any that no longer fit keep their time as fixed-time posts; the response counts them in stranded_posts.
curl -s -X PUT "$API/channels/CHANNEL_ID/schedule" \
-H "Authorization: Bearer $TESSELITH_KEY" \
-H "Content-Type: application/json" \
-d '{"slots":[{"day_of_week":1,"time":"09:00"},
{"day_of_week":3,"time":"09:00"},
{"day_of_week":5,"time":"12:30"}]}'Uploading media
POST /media stores an image or video and returns an id to use in media_ids. Send the file as multipart file, or send JSON {"url": "https://…"} and Tesselith fetches it for you. Images (JPEG, PNG, GIF, WebP) can be up to 32 MB. Video (MP4, MOV, WebM, AVI, WMV, MPEG, 3GP, FLV), PDFs and caption files (VTT, SRT, TTML) can be up to 100 MB through the API. Optional alt_text, width, height and duration_seconds fields are stored with the file; alt text is used on networks that support it.
# From disk
curl -s -X POST "$API/media" -H "Authorization: Bearer $TESSELITH_KEY" \
-F [email protected] -F alt_text="Pumpkin cold brew on a wooden table"
# From a public URL
curl -s -X POST "$API/media" -H "Authorization: Bearer $TESSELITH_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://cdn.example.com/cover.jpg"}'URL uploads only fetch from public HTTPS addresses. Private networks, loopback and cloud metadata addresses are refused, redirects aren't followed, and a fetch that takes longer than 15 seconds fails with URL_UNAVAILABLE.
Analytics
GET /analytics/posts/{id} returns the latest metrics for a published post, per channel and totalled: impressions, likes, comments, shares, saves, clicks, video plays and watch time. A null means the network didn't report that metric, not zero. synced_at tells you how fresh the numbers are.
Errors
Every error is an RFC 9457 problem document with Content-Type: application/problem+json. The code field is stable and safe to switch on; detail is written for people and may change. Validation errors add an errors array with the path and message for each field.
{
"type": "https://tesselith.com/docs/api/errors#validation-error",
"title": "Unprocessable content",
"status": 422,
"detail": "The request isn't valid.",
"code": "VALIDATION_ERROR",
"errors": [{ "path": "scheduled_at", "message": "Invalid ISO datetime" }]
}| Status | Code | What to do |
|---|---|---|
| 401 | UNAUTHORIZED | Key missing, wrong, expired or revoked. Don't retry. |
| 403 | FORBIDDEN | The key's scopes or its creator's role don't allow this. |
| 403 | POST_LIMIT_REACHED | The workspace hit its plan's monthly post limit. |
| 403 | PLAN_UPGRADE_REQUIRED | The workspace's plan doesn't include the API. |
| 404 | NOT_FOUND | No such object in this workspace. |
| 409 | IDEMPOTENCY_IN_PROGRESS | The same Idempotency-Key is still running. Retry shortly. |
| 422 | VALIDATION_ERROR | Fix the fields listed in errors. |
| 422 | NO_QUEUE_SLOT | The channels share no free queue slot. Add times or pick one. |
| 422 | IDEMPOTENCY_KEY_REUSED | The key was used for a different request body. |
| 422 | URL_UNAVAILABLE | A media URL couldn't be fetched safely. |
| 429 | RATE_LIMITED | Wait for Retry-After seconds. |
Idempotency: safe retries
Networks fail in the middle of requests. Without protection, a client that retries a timed-out POST /posts can create the same post twice. Send an Idempotency-Key header (any string up to 255 characters, typically a UUID or your own record id) on every POST. Tesselith stores the first successful response for 24 hours and returns it again for a repeat with the same key, marked with Idempotent-Replayed: true. Errors aren't stored, so you can fix a bad request and retry under the same key. Reusing a key with a different body returns 422 IDEMPOTENCY_KEY_REUSED.
Rate limits
Each key can make 120 requests per minute on Pro, 300 on Team and 600 on Agency. Every response reports where you stand using the IETF RateLimit and RateLimit-Policy headers. When you go over, you get 429 with a Retry-After header giving the seconds until the window resets. Back off for that long instead of retrying in a tight loop.
RateLimit-Policy: "key";q=120;w=60
RateLimit: "key";r=87;t=34Pagination
List endpoints return { "data": [...], "next_cursor": "…" }. Pass next_cursor back as cursor to get the next page, and stop when it's null. limit sets the page size, up to 100. Cursors are opaque: don't parse or construct them.
import os, requests
API = "https://api.tesselith.com/v1"
headers = {"Authorization": f"Bearer {os.environ['TESSELITH_KEY']}"}
cursor = None
while True:
params = {"status": "published", "limit": 100}
if cursor:
params["cursor"] = cursor
page = requests.get(f"{API}/posts", headers=headers, params=params).json()
for post in page["data"]:
print(post["id"], post["scheduled_at"], post["body"][:60])
cursor = page["next_cursor"]
if not cursor:
breakConventions
- JSON fields are
snake_case. Ids are opaque strings. - Timestamps are ISO 8601. Responses are always UTC (
Z); requests must include an offset. - Versioning is in the path. Changes to
/v1are additive only; a breaking change would ship as/v2with a deprecation window. - Webhooks deliver the other direction: see the webhooks guide.
Questions developers ask
- Where do I get a Tesselith API key?
- Workspace owners and admins create keys in Settings → Developers. The key is shown once; copy it into your secret manager straight away.
- Is there an OpenAPI spec for generating a client?
- Yes. https://api.tesselith.com/v1/openapi.json is an OpenAPI 3.1 document generated from the same schemas the server validates with, so generated clients match the real responses.
- Can an API key access more than one workspace?
- No. A key belongs to one workspace. Create a key in each workspace you need to automate.
- Does the API publish immediately?
- The API schedules posts for a time or a queue slot; the publishing pipeline sends them within a minute of their time. To publish as soon as possible, schedule for the current time.
- What happens if my API call times out after creating a post?
- Retry with the same Idempotency-Key. If the first attempt succeeded, you get its original response back instead of a second post.