SimploAPI

Generate posts in your brand's voice and schedule them from your own tools.

A REST API over HTTPS with JSON bodies. Write captions and pictures that follow your brand profile, save them as posts, and schedule them to Instagram, Facebook and TikTok, from curl, a script, CI or any tool that can send an HTTP request. The base URL is https://api.getsimplo.ai/v1.

Working inside an AI assistant instead? The Simplo MCP server does the same through a chat.

Authentication

In the Simplo app, open Settings → API keys and create a key. It is shown once, so copy it then. Send it on every request:

header
Authorization: Bearer smp_live_…

A key acts for your whole account: every project (brand) you own, and your credits. Keep it on a server or in a CI secret, never in a web page or an app. You can have up to 10 keys and revoke any of them at any time in Settings; a revoked key stops working at once. A request without a valid key answers 401 invalid_api_key.

Quickstart

From nothing to a scheduled Instagram post in five steps. The examples use jq to build JSON safely and to read the answers. Before you start, set up the brand profile in the app (Brand): generating captions or pictures for a project without one answers 400 validation_error ("Brand profile not set up"). Connect the platform you post to there too (Brand → Channels).

setup
# Paste your key from Settings → API keys. jq is used below to build and read JSON.
export SIMPLO_API_KEY="smp_live_…"
BASE="https://api.getsimplo.ai/v1"

1Find your project

Most calls name a project, one per brand. This keeps the first one; if you have several brands, look at the list and set PROJECT_ID to the one whose brand profile is set up. The accounts call tells you which platforms can receive posts.

GET /projects
# One project per brand. This keeps the first one's id; with several brands,
# pick the one whose brand profile is set up in the app.
PROJECT_ID=$(curl -sS "$BASE/projects" \
  -H "Authorization: Bearer $SIMPLO_API_KEY" | jq -r '.[0].id')
echo "$PROJECT_ID"

# Which platforms can receive posts (connected in the Simplo app).
curl -sS "$BASE/projects/$PROJECT_ID/accounts" \
  -H "Authorization: Bearer $SIMPLO_API_KEY"

2Write caption ideas (costs credits)

Ideas in your brand's voice about a topic. The answer is { reply, ideas }; each idea has a hook, caption, hashtags and an imagePrompt.

POST /generate/captions
jq -n --arg projectId "$PROJECT_ID" \
  '{projectId: $projectId, topic: "Our new autumn menu", platforms: ["instagram"], limit: 3}' \
| curl -sS "$BASE/generate/captions" \
  -H "Authorization: Bearer $SIMPLO_API_KEY" \
  -H "Content-Type: application/json" \
  --data-binary @- | tee ideas.json

3Make the picture (costs credits)

The answer is { generationId, style, format, imageUrls }. The picture is also saved to the project's image history in the app.

POST /generate/image
# The first idea's imagePrompt describes its picture.
jq --arg projectId "$PROJECT_ID" \
  '{projectId: $projectId, prompt: .ideas[0].imagePrompt, format: "instagram-post"}' ideas.json \
| curl -sS "$BASE/generate/image" \
  -H "Authorization: Bearer $SIMPLO_API_KEY" \
  -H "Content-Type: application/json" \
  --data-binary @- | tee image.json

4Save it as a dated draft

A draft with a scheduledAt is placed on that day in the app's Plan with status draft_scheduled, and never goes out by itself. The answer is the post, with its id.

POST /posts
# The next whole hour, in UTC, e.g. 2026-11-03T13:00:00Z.
AT=$(jq -nr 'now | floor | . - (. % 3600) + 3600 | todate')

jq -n --arg projectId "$PROJECT_ID" --arg at "$AT" \
  --slurpfile ideas ideas.json --slurpfile image image.json \
  '{projectId: $projectId,
    caption: ($ideas[0].ideas[0] | .caption + "\n\n" + (.hashtags // [] | join(" "))),
    imageUrl: $image[0].imageUrls[0],
    platforms: ["instagram"],
    scheduledAt: $at}' \
| curl -sS "$BASE/posts" \
  -H "Authorization: Bearer $SIMPLO_API_KEY" \
  -H "Content-Type: application/json" \
  --data-binary @- | tee post.json

5Schedule it

This is the call that puts it on your real account: the post's status becomes scheduled and Simplo publishes it at that hour. Run it soon after step 4: if that hour has passed by then, the answer is 400 invalid_schedule_time; pass a later scheduledAt instead. Until it goes out you can delete it (DELETE /posts/{id}) or unschedule it in the app.

POST /posts/{id}/schedule
# An empty body keeps the draft's time; send {"scheduledAt": "…"} to change it.
curl -sS "$BASE/posts/$(jq -r .id post.json)/schedule" \
  -H "Authorization: Bearer $SIMPLO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'

The same in Node

Node 18 or later has fetch built in, so this needs no packages.

quickstart.mjs
// quickstart.mjs — Node 18 or later, no packages.
// Run with: SIMPLO_API_KEY=smp_live_… node quickstart.mjs
const BASE = "https://api.getsimplo.ai/v1";

async function simplo(method, path, body) {
  const res = await fetch(BASE + path, {
    method,
    headers: {
      Authorization: `Bearer ${process.env.SIMPLO_API_KEY}`,
      ...(body !== undefined && { "Content-Type": "application/json" }),
    },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const data = res.status === 204 ? null : await res.json();
  if (!res.ok) throw new Error(`${res.status} ${data.error.code}: ${data.error.message}`);
  return data;
}

const [project] = await simplo("GET", "/projects");

const { ideas } = await simplo("POST", "/generate/captions", {
  projectId: project.id,
  topic: "Our new autumn menu",
  platforms: ["instagram"],
  limit: 3,
});
const idea = ideas[0];

const { imageUrls } = await simplo("POST", "/generate/image", {
  projectId: project.id,
  prompt: idea.imagePrompt,
  format: "instagram-post",
});

// The next whole hour, in UTC.
const HOUR = 60 * 60 * 1000;
const scheduledAt = new Date((Math.floor(Date.now() / HOUR) + 1) * HOUR).toISOString();

const draft = await simplo("POST", "/posts", {
  projectId: project.id,
  caption: `${idea.caption}\n\n${(idea.hashtags ?? []).join(" ")}`,
  imageUrl: imageUrls[0],
  platforms: ["instagram"],
  scheduledAt,
});
console.log(draft.id, draft.status); // draft_scheduled: on the calendar, not sent

const scheduled = await simplo("POST", `/posts/${draft.id}/schedule`, {});
console.log(scheduled.status, scheduled.scheduledAt); // scheduled: goes out at that hour

Recipes

GitHub release → post

Every published release becomes a draft post about its notes, on the calendar at a whole hour at least an hour away (generating takes a minute, and the time must still be in the future if you schedule it). Add the key as a repository secret named SIMPLO_API_KEY and your project id as a repository variable named SIMPLO_PROJECT_ID (Settings → Secrets and variables → Actions), and change instagram to the platforms you have connected. To have the post go out on its own, uncomment the last line.

.github/workflows/simplo.yml
# .github/workflows/simplo.yml
# Needs a repository secret SIMPLO_API_KEY and a repository variable SIMPLO_PROJECT_ID.
name: Post releases to Simplo

on:
  release:
    types: [published]

jobs:
  post:
    runs-on: ubuntu-latest
    steps:
      - name: Draft a post about the release
        env:
          SIMPLO_API_KEY: ${{ secrets.SIMPLO_API_KEY }}
          PROJECT_ID: ${{ vars.SIMPLO_PROJECT_ID }}
          # Through the environment, never pasted into the script, so quotes
          # and backticks in the release notes can't break it.
          TOPIC: ${{ github.event.release.body || github.event.release.name || github.event.release.tag_name }}
          IDEMPOTENCY_KEY: release-${{ github.event.release.id }}
        run: |
          set -euo pipefail
          BASE=https://api.getsimplo.ai/v1
          api() {
            curl -sS --fail-with-body "$@" \
              -H "Authorization: Bearer $SIMPLO_API_KEY" \
              -H "Content-Type: application/json"
          }

          # 1. A caption about the release notes.
          jq -n --arg projectId "$PROJECT_ID" --arg topic "$TOPIC" \
            '{projectId: $projectId, topic: $topic, platforms: ["instagram"], limit: 1}' \
            | api "$BASE/generate/captions" --data-binary @- > ideas.json \
            || { cat ideas.json >&2; exit 1; }

          # 2. A picture for it.
          jq --arg projectId "$PROJECT_ID" '{projectId: $projectId, prompt: .ideas[0].imagePrompt}' ideas.json \
            | api "$BASE/generate/image" --data-binary @- > image.json \
            || { cat image.json >&2; exit 1; }

          # 3. A draft on the calendar, never sent by itself. Its time is a whole hour (UTC) at
          #    least an hour away, so it is still in the future if you schedule it below.
          AT=$(date -u -d "@$(( ($(date -u +%s) / 3600 + 2) * 3600 ))" +%Y-%m-%dT%H:%M:%SZ)
          jq -n --arg projectId "$PROJECT_ID" --arg at "$AT" \
            --slurpfile ideas ideas.json --slurpfile image image.json \
            '{projectId: $projectId,
              caption: ($ideas[0].ideas[0] | .caption + "\n\n" + (.hashtags // [] | join(" "))),
              imageUrl: $image[0].imageUrls[0],
              platforms: ["instagram"],
              scheduledAt: $at}' \
            | api "$BASE/posts" -H "Idempotency-Key: $IDEMPOTENCY_KEY" --data-binary @- > post.json \
            || { cat post.json >&2; exit 1; }
          jq '{id, status, scheduledAt}' post.json

          # 4. To publish it at that hour instead of leaving a draft, uncomment:
          # api "$BASE/posts/$(jq -r .id post.json)/schedule" -d '{}'

Idempotency-Key: release-<id> means one release makes one post. Re-running the job writes new captions and a new picture (and spends credits on them), but the post request then differs from the first one, so it is refused with 422 idempotency_key_reused instead of making a second post.

GitHub release → carousel

A carousel is written and drawn in one call and saved as a draft; it posts to Instagram only. Re-running this job in the same hour sends the same request, so the key replays the first carousel instead of paying for another. Re-running it in a later hour changes scheduledAt, so the request differs and is refused with 422 idempotency_key_reused rather than making a second carousel.

If the slides were drawn but saving the draft failed, the answer is 502 upstream_error with the slide URLs in details.slides. Those slides are paid for, so don't retry: use the URLs. A retry with the same key gets the same 502 back and is not charged again.

.github/workflows/simplo-carousel.yml
# .github/workflows/simplo-carousel.yml
# Needs a repository secret SIMPLO_API_KEY and a repository variable SIMPLO_PROJECT_ID.
name: Release carousel on Simplo

on:
  release:
    types: [published]

jobs:
  carousel:
    runs-on: ubuntu-latest
    steps:
      - name: Draft a carousel about the release
        env:
          SIMPLO_API_KEY: ${{ secrets.SIMPLO_API_KEY }}
          PROJECT_ID: ${{ vars.SIMPLO_PROJECT_ID }}
          TOPIC: ${{ github.event.release.body || github.event.release.name || github.event.release.tag_name }}
          IDEMPOTENCY_KEY: release-carousel-${{ github.event.release.id }}
        run: |
          set -euo pipefail
          BASE=https://api.getsimplo.ai/v1
          api() {
            curl -sS --fail-with-body "$@" \
              -H "Authorization: Bearer $SIMPLO_API_KEY" \
              -H "Content-Type: application/json"
          }

          # Writes and draws the slides and saves them as a draft. Its time is a whole hour (UTC)
          # at least an hour away, so it is still in the future if you schedule it below.
          AT=$(date -u -d "@$(( ($(date -u +%s) / 3600 + 2) * 3600 ))" +%Y-%m-%dT%H:%M:%SZ)
          jq -n --arg projectId "$PROJECT_ID" --arg topic "$TOPIC" --arg at "$AT" \
            '{projectId: $projectId, topic: $topic, slideCount: 5, scheduledAt: $at}' \
            | api "$BASE/generate/carousel" -H "Idempotency-Key: $IDEMPOTENCY_KEY" --data-binary @- > carousel.json \
            || { cat carousel.json >&2; exit 1; }
          jq '{postId, status, slides: [.slides[].imageUrl]}' carousel.json

          # To publish it at that hour instead of leaving a draft, uncomment:
          # api "$BASE/posts/$(jq -r .postId carousel.json)/schedule" -d '{}'

Upload a video

Upload an MP4 or MOV (up to 100 MB) as multipart/form-data with the project in the query string, then create the post with the video the upload returned. This continues the quickstart, using its $BASE, $PROJECT_ID and $AT. A video that is already online can skip the upload: pass its link as videoUrl when you create the post.

POST /videos
# 1. Upload the file (MP4 or MOV, up to 100 MB). The type after ; is needed:
#    curl sends an .mp4 as application/octet-stream otherwise. Use video/quicktime for a .mov.
curl -sS "$BASE/videos?projectId=$PROJECT_ID" \
  -H "Authorization: Bearer $SIMPLO_API_KEY" \
  -F "[email protected];type=video/mp4" | tee video.json

# 2. A dated draft with the returned video. Its first frame is the cover unless
#    coverAt names another second (a video that animates in opens on a blank frame).
jq --arg projectId "$PROJECT_ID" --arg at "$AT" \
  '{projectId: $projectId, caption: "A minute with our new feature.",
    video: .video, platforms: ["instagram", "facebook"], scheduledAt: $at}' video.json \
| curl -sS "$BASE/posts" \
  -H "Authorization: Bearer $SIMPLO_API_KEY" \
  -H "Content-Type: application/json" \
  --data-binary @-

Upload a file through a link

For a picture, or a file sent from somewhere that shouldn't hold your key (an AI assistant's sandbox, say): POST /uploads returns an uploadUrl that takes one file for the project within 15 minutes, with no key, and an uploadId to pass when you create or edit the post. A video is an MP4 or MOV up to 100 MB, a picture a JPEG or PNG up to 5 MB. A file no post uses within 24 hours of arriving is deleted.

Opened in a browser, the same uploadUrl shows a page where a person drops the file, from a phone or a laptop. For a link you will hand to someone, pass "forPerson": true: it lasts 24 hours instead of 15 minutes. Opening the page never uses the link up; only sending a file does.

POST /uploads
# 1. A link for one file, valid for 15 minutes (kind: "video" or "image").
curl -sS "$BASE/uploads" \
  -H "Authorization: Bearer $SIMPLO_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"projectId\": \"$PROJECT_ID\", \"kind\": \"image\"}" | tee link.json

# 2. Send the file. No key here: the link is the credential, and takes one file.
curl -sS --fail-with-body -F "[email protected]" "$(jq -r .uploadUrl link.json)"

# 3. A draft with the upload as its picture.
jq --arg projectId "$PROJECT_ID" \
  '{projectId: $projectId, caption: "This month in one chart.",
    uploadId: .uploadId, platforms: ["instagram"]}' link.json \
| curl -sS "$BASE/posts" \
  -H "Authorization: Bearer $SIMPLO_API_KEY" \
  -H "Content-Type: application/json" \
  --data-binary @-

Times

Every scheduledAt is ISO 8601 with a UTC offset (Z or ±hh:mm); a time without one is refused. Simplo publishes once an hour, on the hour, so scheduling needs a whole hour in the future. A half-hour time zone's whole hours (India, +05:30) are fine.

Or skip the offset: send localTime (the time on your clock, YYYY-MM-DDTHH:MM) with timeZone (an IANA name such as America/Edmonton) instead of scheduledAt, and Simplo works out the offset in force on that date, so a zone whose rules changed (Alberta kept -06:00 from November 2026) still lands on the hour you meant. A time the clocks skip or pass twice is refused with invalid_schedule_time. Whenever you send timeZone (also on GET /posts), each post comes back with scheduledLocal: its local time, weekday and offset there.

A draft's date isn't checked until it is scheduled: you can place a draft on any day, and the rules apply when you call schedule.

scheduledAt
2026-11-03T09:00:00-03:00   9 am in São Paulo (12:00 UTC): accepted
2026-11-03T12:00:00Z        the same moment, written in UTC: accepted
2026-11-03T09:00:00         no offset: always 400 validation_error
2026-11-03T09:20:00-03:00   not on the hour: 400 invalid_schedule_time when scheduling (a draft accepts it)
2026-10-01T09:00:00-03:00   in the past: 400 invalid_schedule_time when scheduling (a draft accepts it)

{"localTime": "2026-11-09T12:00", "timeZone": "America/Edmonton"}
  noon in Alberta: Simplo resolves -06:00 (18:00 UTC), and the answer has
  "scheduledLocal": {"localTime": "2026-11-09T12:00", "weekday": "Monday",
                     "timeZone": "America/Edmonton", "utcOffset": "-06:00"}

Credits

Three endpoints cost credits, and the reference marks them (the spec's x-costs-credits): POST /generate/captions (1 credit per call), POST /generate/image (per image) and POST /generate/carousel (the slide copy, plus each picture when pictures is on). Reading, creating posts, scheduling and publishing are free. The prices are in the app under Settings → Credit costs, and GET /credits returns your balance. Credits are taken before the work starts; without enough, the call is refused and nothing is charged:

402
HTTP/1.1 402 Payment Required

{
  "error": {
    "code": "insufficient_credits",
    "message": "Not enough credits for this action.",
    "details": { "allowed": false, "reason": "insufficient_credits", "creditsRemaining": 3, "creditsRequired": 5 }
  }
}

Rate limits

  • 60 requests a minute per key, across all endpoints.
  • 10 requests a minute per key on /generate/*, which each run for tens of seconds.
  • 30 requests a minute per IP address for requests without a valid key, so guessing keys goes nowhere.

Over a limit, the answer is 429 rate_limited with a Retry-After header in seconds. Requests with a key also carry RateLimit and RateLimit-Policy headers. JSON bodies are capped at 1 MB.

Idempotency

A network error after a write leaves you not knowing whether it happened. Send an Idempotency-Key header (any string up to 255 characters) on POST /posts, POST /generate/carousel and POST /posts/{id}/publish, and retry with the same key: within 24 hours, the same key and the same request get the first answer back instead of a second post, with the header Idempotent-Replayed: true.

Idempotency-Key (continuing the quickstart)
jq -n --arg projectId "$PROJECT_ID" --slurpfile image image.json \
  '{projectId: $projectId, caption: "Fresh bread every morning.",
    imageUrl: $image[0].imageUrls[0], platforms: ["instagram"]}' \
| curl -sS -i "$BASE/posts" \
  -H "Authorization: Bearer $SIMPLO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: bread-2026-11-03" \
  --data-binary @-

# Run it again within 24 hours: the same post comes back, not a second one,
# with this header in the answer:
#   Idempotent-Replayed: true
  • The same key with a different body, or for a different post, answers 422 idempotency_key_reused.
  • While the first request is still running, a repeat answers 409 idempotency_in_progress; retry shortly.
  • Kept for replay: successes, and the 404, 413 and 422 answers and 400s from the request handler (such as invalid_schedule_time), which a retry can't change. A body that fails validation is refused before the key is looked at, so it isn't kept.
  • Not kept, so a retry with the same key really runs again: 401, 402, 403, 409, 429 and 5xx answers, platform_not_available (remove X in the app and retry), a publish where a platform failed (allSuccess: false), and a publish still in progress. The one 5xx that is kept is a carousel's 502 with details.slides, whose slides are already paid for.
  • Keys belong to the API key that sent them.

Publishing is safe to retry even without a key: a platform the post already reached is skipped. A post that is already live answers 200 with alreadyPublished: true; a send that is still going (a video, for instance) answers 202 with pending: true, and GET /posts/{id} shows how it ended.

Errors

Every error has the same shape, with a stable code to branch on and a message for people:

the answer to "platforms": ["x"]
{
  "error": {
    "code": "validation_error",
    "message": "The body is not valid.",
    "details": [
      {
        "code": "invalid_value",
        "values": ["instagram", "tiktok", "facebook"],
        "path": ["platforms", 0],
        "message": "Invalid option: expected one of \"instagram\"|\"tiktok\"|\"facebook\""
      }
    ]
  }
}
  • invalid_api_key401

    The key is missing, mistyped or revoked. Only API keys work here, never an app sign-in token.

  • forbidden403

    The key can't do this.

  • not_found404

    No such project, post or endpoint on this account. Another account's ids answer the same way.

  • validation_error400

    The path, query or body is wrong; details lists the failing fields. Also a body that isn't JSON or is over 1 MB, and a platform that is switched off right now.

  • invalid_schedule_time400

    scheduledAt is missing, in the past, or not on a whole hour. See Times.

  • platform_not_available400

    The post includes X (added in the app), which the API doesn't publish to. Remove X in the app.

  • already_published409

    The post has gone out, so it can't be edited or rescheduled.

  • publishing_in_progress409

    The post is being sent right now. Read it again in a moment.

  • conflict409

    The request clashes with the post's current state.

  • idempotency_key_reused422

    This Idempotency-Key was already used with a different request.

  • idempotency_in_progress409

    The first request with this Idempotency-Key is still running. Retry shortly.

  • insufficient_credits402

    Not enough credits. details has creditsRemaining and creditsRequired.

  • subscription_inactive403

    The account has no active plan or trial; details.reason says why.

  • video_too_large413

    The video is over 100 MB.

  • file_too_large413

    The file sent to an upload link is over its limit: 100 MB for a video, 5 MB for a picture. The link can take another file until it expires.

  • upload_link_invalid410

    The upload link has expired (links last 15 minutes) or already took its file. Create a new one.

  • rate_limited429

    Too many requests. Wait the number of seconds in Retry-After.

  • upstream_error502

    A social platform or an AI provider failed. Retry after a short wait, except for a carousel whose answer has details.slides: those slides were drawn and paid for, and only saving the draft failed. Don't retry it; use the slide URLs.

  • internal_error500

    Something went wrong on Simplo's side.

Platforms

The API posts to Instagram, Facebook and TikTok: instagram, facebook and tiktok in platforms. Accounts are connected in the app, and GET /projects/{projectId}/accounts shows which are ready. A platform switched off on Simplo's side for a while is refused with validation_error.

X isn't available through the API yet. A post made in the app that includes X can't be scheduled or published from here (platform_not_available). Carousels go to Instagram only; a video goes to Instagram as a Reel, to Facebook and to TikTok.

Reference

Generated from the OpenAPI 3.1 document, which you can download as openapi.json to generate a client or import into Postman. The API serves the same document at GET /v1/openapi.json.

get/projects

List projects listProjects

The projects (one per brand) the key owner has. Most other endpoints need a project id from here.

Responses
get/projects/{projectId}/brand

Get the brand profile getBrand

The project's brand profile: voice, audience, products, colors, typography.

Parameters
  • projectId (path)required
    string

    A project id from List projects.

Responses
get/projects/{projectId}/accounts

List connected accounts listAccounts

The social accounts connected to the project and the platforms that are not. A post only goes out to a connected platform that does not need a reconnect. Accounts are connected in the Simplo app.

Parameters
  • projectId (path)required
    string

    A project id from List projects.

Responses
get/credits

Get the credit balance getCredits

The plan and credits. creditsRemaining is everything that can be spent: the plan's credits, used first, plus bonus credits. planCredits describes the plan's monthly allowance only.

Responses
get/posts

List posts listPosts

The project's posts (drafts, scheduled, published, failed, missed), newest first. A draft_scheduled post is a draft placed on its day: it never sends by itself. from and to select the posts planned in a range, which can span months.

Parameters
  • projectId (query)required
    string

    The project whose posts to list.

  • month (query)
    string

    Only posts of this month, as YYYY-MM.

  • from (query)
    string (date-time)

    Only posts planned at or after this time, e.g. 2026-11-02T00:00:00-03:00.

  • to (query)
    string (date-time)

    Only posts planned before this time.

  • status (query)
    string

    Only posts with one of these statuses, comma-separated, e.g. scheduled,draft_scheduled.

  • platform (query)
    instagram | tiktok | facebook

    Only posts that go to this platform.

  • timeZone (query)
    string

    An IANA time zone, e.g. America/Edmonton: each post's scheduledLocal shows its time on that zone's clock.

Responses
post/posts

Create a post createPost

Saves a single-image or video post as a draft (the default) or scheduled. A draft with scheduledAt is placed on that day in the plan (draft_scheduled) and never sends by itself. A scheduled post is published automatically at scheduledAt, which must be in the future and on the hour. Instead of scheduledAt, pass localTime with timeZone and the server works out the UTC offset; with timeZone, scheduledLocal in the answer shows how the saved time reads there. A video post takes videoUrl (a public MP4 or MOV link, up to 100 MB) or an already uploaded video; its first frame is the cover unless coverAt names another second. A file you have on disk goes through Create an upload link and is passed as uploadId. Not for X. Pass at most one of imageUrl, video, videoUrl and uploadId.

Parameters
  • Idempotency-Key (header)
    string · up to 255 characters

    Up to 255 characters. Repeating a request with the same key within 24 hours returns the first answer instead of doing the work again.

JSON body
  • projectIdrequired
    string

    The project that owns the post.

  • captionrequired
    string

    The post text.

  • imageUrl
    string (uri)

    A public picture URL. Mutually exclusive with video, videoUrl and uploadId.

  • video
    object

    A video already uploaded with Upload a video. Mutually exclusive with imageUrl, videoUrl and uploadId.

  • videoUrl
    string (uri)

    A public MP4 or MOV link for Simplo to fetch. Mutually exclusive with imageUrl, video and uploadId.

  • uploadId
    string

    A file sent to an upload link (Create an upload link): its video, or its picture. Mutually exclusive with imageUrl, video and videoUrl.

  • coverAt
    number

    Video posts only: the second of the video its cover is taken from, at least 0 and less than its length, rounded to hundredths. The cover is the picture Instagram shows on the profile grid; without coverAt it is the first frame, which is blank for a video that animates in.

  • aiGenerated
    boolean

    TikTok only: true shows the post on TikTok with its "AI-generated content" label, false never does. Without it the brand's "Label TikTok posts as AI-generated" setting applies when on; otherwise pictures are labelled and videos are not.

  • tiktokSettings
    object

    TikTok accounts connected directly: the choices the user made for this post. Required to schedule such a post. Never fill these in for the user.

  • platformsrequired
    array of instagram | tiktok | facebook · at least 1

    Where the post goes. X is not supported.

  • status
    draft | scheduled · default "draft"

    draft saves it; scheduled publishes it at its time (scheduledAt, or localTime with timeZone).

  • scheduledAt
    string (date-time)

    ISO 8601 with UTC offset. Scheduling needs a future whole hour; a draft's date isn't checked until it is scheduled. localTime with timeZone is safer: the server works out the offset.

  • localTime
    string

    The time on the user's clock, YYYY-MM-DDTHH:MM with no offset (e.g. 2026-11-09T12:00), with timeZone. The server resolves the UTC offset in force on that date; a time the clocks skip or pass twice is refused with invalid_schedule_time. Not together with scheduledAt.

  • timeZone
    string

    An IANA time zone, e.g. America/Edmonton. Needed with localTime; with scheduledAt it only sets the zone scheduledLocal is given in.

Responses
get/posts/{id}

Get a post getPost

One post, with its status and any publish error.

Parameters
  • id (path)required
    string
Responses
patch/posts/{id}

Edit a post updatePost

Changes a post's caption, picture, video, video cover (coverAt), TikTok AI label (aiGenerated) or platforms; send only what changes. scheduledAt places a draft on a day without scheduling it; a scheduled post's time is changed with the schedule endpoint. Editing a scheduled post changes what goes out at its time. A post that has gone out, or is going out now, cannot be edited. Send at least one field, and at most one of imageUrl, video, videoUrl and uploadId.

Parameters
  • id (path)required
    string
JSON body
  • caption
    string

    The new post text.

  • imageUrl
    string (uri)

    A public picture URL. Mutually exclusive with video, videoUrl and uploadId.

  • video
    object

    A video already uploaded with Upload a video. Mutually exclusive with imageUrl, videoUrl and uploadId.

  • videoUrl
    string (uri)

    A public MP4 or MOV link for Simplo to fetch. Mutually exclusive with imageUrl, video and uploadId.

  • uploadId
    string

    A file sent to an upload link (Create an upload link): its video, or its picture. Mutually exclusive with imageUrl, video and videoUrl.

  • coverAt
    number

    Video posts only: the second of the video its cover is taken from, at least 0 and less than its length, rounded to hundredths. The cover is the picture Instagram shows on the profile grid; without coverAt it is the first frame, which is blank for a video that animates in.

  • aiGenerated
    boolean,null

    TikTok only: true shows the post on TikTok with its "AI-generated content" label, false never does. Without it the brand's "Label TikTok posts as AI-generated" setting applies when on; otherwise pictures are labelled and videos are not. null puts the post back on the default.

  • tiktokSettings
    any
  • platforms
    array of instagram | tiktok | facebook · at least 1

    The new list of platforms.

  • scheduledAt
    string (date-time)

    ISO 8601 with UTC offset. Scheduling needs a future whole hour; a draft's date isn't checked until it is scheduled. localTime with timeZone is safer: the server works out the offset.

  • localTime
    string

    The time on the user's clock, YYYY-MM-DDTHH:MM with no offset (e.g. 2026-11-09T12:00), with timeZone. The server resolves the UTC offset in force on that date; a time the clocks skip or pass twice is refused with invalid_schedule_time. Not together with scheduledAt.

  • timeZone
    string

    An IANA time zone, e.g. America/Edmonton. Needed with localTime; with scheduledAt it only sets the zone scheduledLocal is given in.

Responses
delete/posts/{id}

Delete a post deletePost

Deletes a post from Simplo; this cannot be undone. A published post stays on the social platforms: only Simplo's record and its analytics go. Deleting a scheduled post stops it from going out.

Parameters
  • id (path)required
    string
Responses
get/projects/{projectId}/analytics

Get post metrics getPostMetrics

How the published posts did: one row per post and platform with the numbers the platform reports (a number it does not report is left out), read live from Instagram and TikTok on every call. A row with error could not be read this time (the account needs a reconnect, or TikTok has not given the video an id yet); its numbers are the last good ones. Each row says what the post was made as: postType, style (the carousel style its slides were drawn in, if any) and durationSeconds (a video's length). Instagram reports profileVisits for feed posts and carousels, never for Reels. overview totals the posts Simplo published in the range; with groupBy, overview.groups breaks it down by postType or carousel style, with totals and medians over every row in the range. With outside=true, outside also lists the posts on the connected accounts that Simplo did not publish (made in the platform's app or another tool), and outside.accounts says which accounts were read and why any could not be.

Parameters
  • projectId (path)required
    string

    A project id from List projects.

  • days (query)
    7 | 30 | 90

    Posts published in the last 7, 30 (the default) or 90 days.

  • platform (query)
    instagram | tiktok

    Only this platform.

  • sort (query)
    recent | engagement | reach | views

    recent (newest first, the default), engagement, reach or views (highest first).

  • order (query)
    desc | asc

    desc (the default: highest or newest first) or asc (lowest or oldest first). Rows without the number come last either way.

  • outside (query)
    true | false

    true also lists the posts on the accounts that Simplo did not publish.

  • groupBy (query)
    postType | style

    Break the overview down by postType (carousels against videos and pictures) or style (one carousel style against another).

Responses
get/projects/{projectId}/account-summary

Get the account summary getAccountSummary

How each connected Instagram and TikTok account is doing: followers now, and 7 and 28 days ago (the latest count on or before that day, and the change since). Simplo reads follower counts once a day, so a comparison appears only once the counts reach that far back: historyFrom says how far they go, and notes say what is missing. Follower counts are read for Instagram only; an account with collected: false says why in reason. Also the posts published in the range ranked by engagement: posts.top (highest first) and posts.bottom (lowest first, never a post already in top). Nothing is read from the platforms: the post numbers are as Simplo last read them (lastSyncedAt), and Get post metrics reads them live.

Parameters
  • projectId (path)required
    string

    A project id from List projects.

  • from (query)
    string (date-time)

    Only posts published at or after this time, e.g. 2026-10-01T00:00:00-03:00. The default is 28 days before to.

  • to (query)
    string (date-time)

    Only posts published before this time. The default is now.

  • limit (query)
    string

    Posts in top and in bottom, each: 1 to 20, 5 by default.

Responses
post/generate/captionscosts credits

Generate caption ideas generateCaptions

On-brand post ideas (hook, caption, hashtags, image prompt) for a topic, saved as a research session. Costs credits. Needs a completed brand profile.

JSON body
  • projectIdrequired
    string

    The project whose brand the ideas follow.

  • topicrequired
    string

    What the posts are about.

  • platforms
    array of instagram | tiktok | facebook

    Shape the ideas for these platforms.

  • limit
    integer · 1 to 10

    How many ideas, 1 to 10.

Responses
post/generate/imagecosts credits

Generate images generateImage

On-brand images for a post, saved to the project's image history. Costs credits (more for more images). Style "ad-layout" sets real type over a photo; other styles draw text badly. Every model tier currently runs as "standard".

JSON body
  • projectIdrequired
    string

    The project whose brand the images follow.

  • promptrequired
    string

    What the image shows.

  • format
    instagram-post | instagram-story | instagram-portrait | tiktok | x-post | facebook-post | facebook-cover · default "instagram-portrait"

    The picture's size. instagram-portrait (4:5) is the default: Instagram's profile grid shows posts as 3:4, which cuts the sides of a square.

    Picture sizes, not platforms: x-post is a picture shaped for X, and X itself isn't a platform the API posts to.

  • style
    ad-layout | studio | candid | cinematic | retro-film | flat-illustration | hand-drawn | render-3d | bold-type | minimal · default "studio"
  • adBackground
    image | brand · default "image"
  • modelTier
    fast | standard | premium · default "standard"
  • imageCount
    integer · 1 to 4 · default 1

    How many images, 1 to 4. Each one costs credits.

  • includeLogo
    boolean · default false
Responses
post/generate/carouselcosts credits

Generate a carousel generateCarousel

Writes and draws an Instagram carousel about a topic and saves it as a draft post. Costs credits: the slide copy, plus each picture when pictures are on. It is never published or scheduled here. A slide in overflowSlides has more text than fits. If saving the draft fails after the slides were drawn, the answer is 502 with the slide URLs in details.slides: those slides are paid for, so use the URLs rather than retrying (a retry with the same Idempotency-Key gets the same answer).

Parameters
  • Idempotency-Key (header)
    string · up to 255 characters

    Up to 255 characters. Repeating a request with the same key within 24 hours returns the first answer instead of doing the work again.

JSON body
  • projectIdrequired
    string

    The project that owns the carousel.

  • topicrequired
    string

    What the carousel is about.

  • slideCount
    integer · 2 to 10 · default 5

    How many slides, 2 to 10.

  • style
    editorial | clean | boldPhoto | dark
  • pictures
    boolean · default false

    Draw a picture on each slide. Costs extra credits.

  • caption
    string

    The post text; written for you when left out.

  • scheduledAt
    string (date-time)

    ISO 8601 with UTC offset. Scheduling needs a future whole hour; a draft's date isn't checked until it is scheduled. localTime with timeZone is safer: the server works out the offset.

  • localTime
    string

    The time on the user's clock, YYYY-MM-DDTHH:MM with no offset (e.g. 2026-11-09T12:00), with timeZone. The server resolves the UTC offset in force on that date; a time the clocks skip or pass twice is refused with invalid_schedule_time. Not together with scheduledAt.

  • timeZone
    string

    An IANA time zone, e.g. America/Edmonton. Needed with localTime; with scheduledAt it only sets the zone scheduledLocal is given in.

  • replacesPostId
    string

    A draft this carousel replaces: the new carousel takes its day in the plan (unless a time is given) and the old draft is deleted. Only a draft that never went out can be replaced.

  • goal
    tips | promo

    With a style: "tips" teaches and closes on a save; "promo" sells and closes on the brand's offer. Read from the topic when left out.

Responses
post/posts/{id}/schedule

Schedule a post schedulePost

Schedules a post, or moves a scheduled one to a new time. It is published to the connected accounts automatically at scheduledAt, which must be in the future and on the hour; without it the post keeps the time it has. Instead of scheduledAt, pass localTime with timeZone and the server works out the UTC offset; scheduledLocal in the answer shows how the time reads there. Works on drafts, scheduled, missed and failed posts. A post that already went out cannot be rescheduled. Refused for a post that includes X.

Parameters
  • id (path)required
    string
JSON body
  • scheduledAt
    string (date-time)

    ISO 8601 with UTC offset. Scheduling needs a future whole hour; a draft's date isn't checked until it is scheduled. localTime with timeZone is safer: the server works out the offset.

  • localTime
    string

    The time on the user's clock, YYYY-MM-DDTHH:MM with no offset (e.g. 2026-11-09T12:00), with timeZone. The server resolves the UTC offset in force on that date; a time the clocks skip or pass twice is refused with invalid_schedule_time. Not together with scheduledAt.

  • timeZone
    string

    An IANA time zone, e.g. America/Edmonton. Needed with localTime; with scheduledAt it only sets the zone scheduledLocal is given in.

Responses
post/posts/{id}/publish

Publish a post now publishPost

Publishes the post to its platforms immediately, on the real accounts; this cannot be undone. Safe to retry: a platform already published is skipped. The answer lists a result per platform and allSuccess. A send that is still in progress (a video, or a post going through a posting partner) answers 202 with pending: true; read the post for the result. A post that is already live answers 200 with alreadyPublished: true. Refused for a post that includes X.

Parameters
  • id (path)required
    string
  • Idempotency-Key (header)
    string · up to 255 characters

    Up to 255 characters. Repeating a request with the same key within 24 hours returns the first answer instead of doing the work again.

Responses
post/videos

Upload or import a video uploadVideo

Hosts a video for a video post. Send an MP4 or MOV (up to 100 MB) as multipart/form-data in the file field, with projectId in the query string, or send JSON with a public url for Simplo to fetch, with projectId in the body or the query. The upload is streamed, never held in memory. Pass coverAt (in the query for an upload) to take the cover from a later second. Pass the returned video to Create a post as video.

Parameters
  • projectId (query)
    string

    The project that owns the video. Required for an upload.

  • coverAt (query)
    number

    The second of the video its cover is taken from, less than its length; the first frame without it. Create a post and Edit a post take it too.

JSON body
  • projectId
    string

    Required here or in the query string.

  • urlrequired
    string (uri)

    A public MP4 or MOV link for Simplo to fetch.

  • coverAt
    number

    The second of the video its cover is taken from, less than its length; the first frame without it. Create a post and Edit a post take it too.

Or a multipart/form-data body
  • filerequired
    string (binary)

    MP4 or MOV, up to 100 MB

Responses
post/uploads/{token}no key needed

Upload a file to an upload link uploadFile

Send the file as multipart/form-data in the file field, e.g. curl -F "[email protected]" <uploadUrl>. No API key: the link from Create an upload link is the credential, takes one file, and expires 15 minutes after it was made (24 hours for a link made with forPerson). Opened in a browser (GET), the same URL shows a page where a person can drop the file. A refused file (wrong type, too large) leaves the link usable until then. Answers with the uploadId and the hosted file.

Parameters
  • token (path)required
    string
Or a multipart/form-data body
  • filerequired
    string (binary)

    The video (MP4 or MOV, up to 100 MB) or picture (JPEG or PNG, up to 5 MB) the link was made for

Responses
get/openapi.jsonno key needed

Get the OpenAPI document getOpenApi

This API described as an OpenAPI 3.1 document. No API key needed.

Responses