Let your agent upload, schedule and post.
An MCP server, a CLI and a REST API over the same scheduler the dashboard uses. Point Claude, Cursor or your own agent at it and it can ingest media, validate against every platform’s rules, and schedule to Instagram, Facebook, TikTok, X, Pinterest and YouTube.
Quickstart
Create a key, connect an MCP client, and get a first post on the calendar. About five minutes.
Create an API key
In the dashboard, go to Settings → API keys and create a key. Choose a scope preset while you are there — draft is the default and the one to start with: an agent using it can compose posts, but every post lands as a draft for you to approve.
utsk_live_xxxxxxxxxxxx.your-secret-hereorganization_id parameter anywhere in the API, because the key already decides which workspace you are talking to.Every plan can create keys, free Hobby included. Plans differ by how fast you may call: 20 requests a minute on Hobby, then 60, 120 and 300. See pricing.
Connect Claude
Point Claude Code at the remote MCP server. Nothing to install.
claude mcp add --transport http pulsarsocial \
https://getpulsarsocial.com/mcp \
--header "Authorization: Bearer utsk_live_xxxxxxxxxxxx.your-secret-here"For Claude Desktop, Cursor, VS Code and anything else that only speaks stdio, see MCP setup below.
Ask for a post
The agent should do three things in order: list your connected accounts, validate a draft payload against the platform rules, then create the post.
Draft a post announcing our Tuesday launch and put it on
the calendar for 9am Tuesday, Instagram and X. Validate it
before you create anything.With a draft key it comes back as a draft, waiting for you in the dashboard. With a write key it is scheduled for real.
The workflow that works
Every posting task follows the same three steps, and skipping one is how agents produce payloads that fail:
- list_accounts — every target needs a real
social_account_id. They cannot be guessed from a handle. - validate_post — a dry run. Writes nothing, consumes no quota, never touches a live account, and returns per-target errors. Iterate here until it is clean.
- create_post — one call, all targets. Read the response rather than assuming it scheduled.
API keys
One key, one workspace. Everything below — the connector, the skill, the CLI, the REST API — authenticates with the same string.
Create one in the dashboard under Settings → API keys. The secret half is shown once, at creation; after that only a masked prefix is stored, so a lost key has to be revoked and replaced rather than recovered.
utsk_live_xxxxxxxxxxxx.your-secret-herePick the narrowest scope that works
| Preset | An agent holding it can |
|---|---|
| read | Look at posts, accounts, media and usage. Change nothing. |
| draft | Write posts as drafts for a human to approve. The default, and the one to start with. |
| write | Schedule and publish straight to live accounts, with nobody reviewing first. |
organization_idparameter anywhere in the API. The key decides which workspace you are talking to, which is why a key from one workspace returns 404 — not 403 — against another’s ids.What each plan gets
| Plan | Keys | Requests / min |
|---|---|---|
| Hobby (free) | 3 | 20 |
| Starter | 10 | 60 |
| Growth | 25 | 120 |
| Scale | 100 | 300 |
Every plan has API access, the free one included — plans differ by ceiling, not by capability. See pricing.
MCP server
Give Claude, ChatGPT, Gemini or your own agent direct access to your workspace — see what is connected, draft a post, schedule it. Pick your client.
Claude desktop & web
Open Settings → Connectors → Add custom connector. Paste this as the URL, leave the OAuth fields empty, and click Add.
https://getpulsarsocial.com/mcp?apiKey=utsk_live_xxxxxxxxxxxx.your-secret-hereThis URL contains your API key — treat it like a password. Rotate it any time from Settings → API keys.
Claude Code
One command, and the key travels as a header rather than in the URL.
claude mcp add --transport http pulsarsocial \
https://getpulsarsocial.com/mcp \
--header "Authorization: Bearer utsk_live_xxxxxxxxxxxx.your-secret-here"Raw endpoint details
Endpoint: https://getpulsarsocial.com/mcp
Transport: Streamable HTTP (stateless)
Auth: Authorization: Bearer utsk_live_xxxxxxxxxxxx.your-secret-here
…or ?apiKey= in the URL, for clients with no header fieldClients that can only spawn a local process use the @pulsarsocial/mcp stdio bridge, which forwards JSON-RPC to this same endpoint. It implements no tools of its own, so the tool list never goes stale.
npx -y @pulsarsocial/mcp| Variable | Required | Default |
|---|---|---|
| PULSARSOCIAL_API_KEY | yes | — |
| PULSARSOCIAL_BASE_URL | no | https://getpulsarsocial.com |
Agent skill
A file that teaches an agent the rules before it posts, so it gets one right on the first attempt instead of the third.
The MCP server gives an agent the tools. This gives it the judgement: validate before creating, that X counts 280 weighted characters where every link costs 23, that a draft-scoped key saves rather than schedules, and that reusing an idempotency key on retry is what stops a double-post.
Save it as pulsarsocial/SKILL.mdin your agent’s skills folder — for Claude Code that is ~/.claude/skills/pulsarsocial/SKILL.md — and it loads itself whenever a posting task comes up.
---
name: pulsarsocial
description: Schedule and publish social media posts through PulsarSocial (getpulsarsocial.com) — Instagram posts/reels/stories, Facebook Pages, TikTok videos, X/Twitter tweets, YouTube videos and Shorts, and Pinterest pins — from one content calendar. Use when asked to draft, schedule, queue, plan, publish, reschedule, cancel or audit social posts, build a content calendar or posting plan, check which social accounts are connected, upload or ingest media for a post, or check posting quota. Covers the PulsarSocial MCP tools (list_accounts, validate_post, create_post, …) and the REST API at https://getpulsarsocial.com/api/v1.
---
# PulsarSocial
PulsarSocial schedules one post to many platforms at once. You write the content, pick which connected accounts it goes to, give it a time, and PulsarSocial publishes it.
Everything below assumes you have the PulsarSocial MCP tools available. If you don't, the same operations exist as REST at `https://getpulsarsocial.com/api/v1` with an `Authorization: Bearer utsk_live_…` header, and the shapes are identical.
## The one workflow that works
```
list_accounts → validate_post → create_post
```
Do not skip steps. Each one exists because the step after it fails without it.
### 1. `list_accounts` — always first
Every target in a post needs a real `social_account_id`. These are opaque ids belonging to the user's workspace; you cannot guess them, derive them from a handle, or reuse one from an earlier conversation. Call `list_accounts` at the start of any posting task, even if the user only mentioned one platform.
It also tells you what is *possible*. If the user says "post this to LinkedIn" and `list_accounts` returns no LinkedIn account, say so immediately rather than constructing a payload that will 404.
**Connecting an account is not something you can do.** It is an interactive OAuth flow in a browser — the user has to visit getpulsarsocial.com and click through the platform's consent screen. There is no API for it, by design. If an account is missing, tell the user to connect it in the dashboard and stop.
### 2. `validate_post` — use it constantly
`validate_post` takes the exact payload you would pass to `create_post` and tells you whether it would succeed. It is a dry run:
- writes nothing
- consumes no quota
- never touches a live social account
- returns per-target errors and warnings, not one generic "invalid"
This is the single most useful tool in the set. Platform rules are fiddly and inconsistent, and the cost of guessing wrong on `create_post` is a real post on a real account. So: **build a payload, validate it, fix what it complains about, validate again, and only then create.** Two or three validate round-trips before a create is normal and correct, not wasteful.
Validate again after any edit, including ones that look harmless. Adding a link to an X post can push it over the limit on its own (see below).
### 3. `create_post`
Only after a clean validate. Pass an `Idempotency-Key` (see below). Then **read the response** — do not assume what happened.
## Scheduling, and how to post right now
`scheduled_at` is an ISO timestamp with an offset, and it is the publish moment — there is no separate "post now" flag.
**To publish immediately, pass the current time.** A timestamp slightly in the past is accepted and treated as due now (there is a few-minutes' grace, which also absorbs clock skew between you and the server); anything older than that is rejected as `SCHEDULED_AT_IN_PAST`. Do not try to fake immediacy by scheduling a minute or two ahead — that just makes the user wait.
Two things worth telling the user rather than letting them discover:
- Staging starts about **8 minutes before** `scheduled_at`, so a post scheduled sooner than that begins uploading right away. This is normal, not an early publish.
- "Immediately" still means *seconds to minutes*, not instant. The media has to be validated, uploaded to the platform and encoded by it before it goes live. A large video takes longer.
When the user gives a wall-clock time ("post at 11:30"), resolve **AM vs PM explicitly** and echo the full resolved timestamp back to them. Getting this wrong silently schedules a post 12 hours away, which reads to the user as a stuck upload rather than a mistake.
## Draft scope: read the response
API keys carry one of three scope presets: `read`, `draft`, `write`. `draft` is the default and the safe one.
With a **`draft`-scoped key, `create_post` saves a DRAFT and does not schedule it.** The post sits in the workspace for a human to review and approve. The response says so explicitly — its status is a draft status and there is no scheduled publish.
This means: never report "scheduled for 9am Tuesday" because you called `create_post` and got a 200. Read the returned status and tell the user what actually happened — "saved as a draft in PulsarSocial, approve it in the dashboard to schedule it." Getting this wrong makes the user think something is queued when it isn't.
A `write`-scoped key schedules and publishes for real. If the user's key is `write`, treat `create_post` as an irreversible action against their audience and confirm before firing.
## Per-platform rules that will bite you
`validate_post` enforces all of these. This list is here so you can get it right on the first attempt instead of the third.
### X (Twitter)
The limit is **280 weighted characters**, not 280 characters:
- Every URL counts as **23** characters regardless of its actual length. A long tracking URL and a short link cost exactly the same.
- CJK characters (Chinese, Japanese, Korean) and most emoji count as **2**.
- Latin text counts as 1.
So a 250-character draft plus one link is 273 and fits; plus two links it is 296 and does not. Never estimate this by eye — validate.
### YouTube
- Descriptions cap at **5,000 UTF-8 bytes**, not 5,000 characters. Emoji are 4 bytes each and accented Latin is 2, so a description that is comfortably under 5,000 characters can still be rejected. If a description is emoji-heavy, assume the byte count is much higher than the character count.
- Titles cap at 100 characters.
### Instagram
- Captions cap at **2,200 characters**.
- `post_and_story` is not one publish — it **forks into two independent targets**. Either can fail on its own, so the result can be partial: the feed post lands and the story doesn't. Report per-target results, not a single verdict.
### TikTok
- Requires an explicit **music-usage agreement flag** on the payload. There is no sensible default for it because it is a legal acknowledgement — if the user has not given it, ask, don't assume.
- If the workspace's TikTok app is **unaudited**, posts are restricted to **`SELF_ONLY` visibility**. Anything else is rejected. This is a TikTok platform restriction, not a PulsarSocial one, and it cannot be worked around.
- **`post_mode`** picks what "posting" means, and the two are genuinely different outcomes:
- `"direct"` (default) publishes to the profile.
- `"draft"` uploads the media to the creator's **TikTok inbox** and posts nothing. They get a notification, open the TikTok app, and finish the post themselves — choosing the caption, sound and privacy in TikTok's own editor.
- In `draft` mode TikTok **ignores** `privacy_level`, `disable_comment`, `disable_duet`, `disable_stitch`, `auto_add_music` and both brand toggles, because the creator sets all of that themselves. Sending them is harmless but they will not take effect — so never tell the user you set a privacy level on a TikTok draft.
- A TikTok draft reaches `PUBLISHED` in PulsarSocial once the media is **delivered to the inbox**, which is not the same as being live on TikTok. There is no permalink, because no post exists yet. Say "sent to your TikTok drafts — open TikTok to finish it", never "posted".
- `post_mode: "draft"` is unrelated to `is_draft` and to the `draft` key scope. `is_draft` parks a post inside PulsarSocial; `post_mode` decides what TikTok does once it *is* sent. A post can be one, both or neither.
### Pinterest
- Requires a **`board_id`**. A pin with no board has nowhere to go. Get valid boards from the account's entry in `list_accounts` before building the payload.
### Facebook
- Page posts only. Personal profiles are not publishable via the platform API.
## Quota is per post, not per target
One post fanned out to six platforms is **one** unit of quota. Six separate single-platform posts are six.
So when a user wants the same content on Instagram, TikTok, X, YouTube, Pinterest and Facebook, put all six targets in **one** `create_post` call. Splitting it into six calls costs 6× the quota for an identical outcome, and gives the user six calendar entries to manage instead of one.
Split into separate posts only when the *content itself* genuinely differs per platform — a different caption for X than Instagram, say. Per-target caption overrides are usually the better answer even then; check `validate_post` output before deciding you need separate posts.
Use `get_usage` to check remaining quota before a large batch.
## Idempotency: reuse the key on retry
Mutating calls accept an `Idempotency-Key`.
- Generate one fresh UUID per **logical** post.
- On a retry — timeout, 5xx, network error, tool error you're unsure about — **send the exact same key again**. The server recognises it and returns the original result instead of creating a second post.
- Generating a new key on retry is how you double-post to someone's real audience. This is the most damaging mistake available in this API, and it is entirely avoidable.
If a `create_post` fails ambiguously and you did not set an idempotency key, do **not** blindly retry. Call `list_posts` first and check whether it actually landed.
## Media
Two paths:
- **`ingest_media`** — hand it a public URL and PulsarSocial fetches it server-side. Cap is 100 MB. This is the easy path and covers most cases.
- **Upload URL flow** (`/media/upload-url` then `/media/register` in REST) — for larger files or bytes you hold locally.
`list_media` shows what is already in the workspace library. Check there before re-ingesting the same asset.
Media has its own per-platform constraints (aspect ratio, duration, codec). `validate_post` checks these too, which is another reason to validate before creating.
## Reading and fixing posts
- `list_posts` — keyset paginated. Responses are `{ data: [...], next_cursor, has_more }`. Follow `next_cursor` while `has_more` is true; do not assume page numbers.
- `get_post` — full detail including per-target status.
- `reschedule_post` — move a scheduled post. Prefer this over delete-and-recreate; it keeps history and costs no extra quota.
- `cancel_post` — stop a scheduled post from publishing, keep the record.
- `delete_post` — remove it entirely.
## Errors
Errors come back as `{ error, code, request_id }`. Quote the `request_id` to the user when reporting a failure they may need support for.
| Status | Meaning | What to do |
| --- | --- | --- |
| 401 | Key invalid or revoked | Stop. The user needs a new key from Settings → API keys. |
| 402 `PLAN_REQUIRED` | Plan limit or key cap hit | Stop. Retrying will not help. |
| 403 `INSUFFICIENT_SCOPE` | Key's scope doesn't cover this | Stop. Do not try a different tool to get around it — tell the user their key is read/draft scoped. |
| 404 | Unknown id, or an id from another workspace | Re-run `list_accounts` / `list_posts`; a stale id from earlier in the conversation is the usual cause. |
| 422 | Validation failed | Fix the payload and `validate_post` again. |
| 429 | Rate limited | Back off for `Retry-After` seconds. Limits are per key per minute: 20 / 60 / 120 / 300 by plan. |
401, 402 and 403 are terminal. Report them and stop; they will not resolve by trying again.
## Things to just know
- A key is scoped to **one workspace**. `organization_id` is never a request parameter — the key determines the workspace, and you cannot address another one.
- Times: always confirm the user's intended timezone before scheduling. "Tuesday at 9" is ambiguous and a wrong timezone means a post at 3am.
- API access is on every plan, free included. Plans differ by rate limit: 20/min on Hobby, then 60 / 120 / 300.
- Full docs: <https://getpulsarsocial.com/docs>Command line
Post, schedule and manage media straight from your terminal, or from a CI job.
Sign in
export PULSARSOCIAL_API_KEY=utsk_live_xxxxxxxxxxxx.your-secret-hereList your accounts
npx pulsarsocial accountsSchedule & publish
Write the post as JSON, check it, then create it.
npx pulsarsocial validate post.json
npx pulsarsocial create post.jsonvalidate writes nothing and consumes no quota, and exits non-zero if the post would be rejected — so the two chain safely with &&.
Install it once with npm install -g pulsarsocial and drop the npx.
All commands
| Command | Does |
|---|---|
| whoami | Workspace, plan, scopes, rate limit |
| usage | Quota used and remaining |
| accounts | Connected accounts and their ids |
| posts | Scheduled and published posts |
| post <id> | One post in full, per-target status |
| media | The workspace media library |
| ingest <url> | Pull a public URL into the library |
| validate <file> | Dry run — writes nothing |
| create <file> | Create a post |
| cancel <id> | Stop a scheduled post, keep the record |
| retry <id> | Retry a failed post |
| reschedule <id> <time> | Move it (ISO 8601) |
| delete <id> | Remove it entirely |
Options: --key, --base-url, --json, --idempotency-key.
Scripting and exit codes
--json emits the raw response instead of tables:
npx pulsarsocial posts --json | jq '.data[] | select(.status=="failed")'| Exit | Means |
|---|---|
| 0 | Success |
| 1 | Might work later — network, rate limit, server error |
| 2 | Will never work as sent — bad key, wrong plan, insufficient scope, invalid post |
create always sends an Idempotency-Key. If one is interrupted, pass the same value back with --idempotency-key and the server returns the original result rather than posting twice.Connecting accounts is not in the API
Linking an Instagram, Facebook, TikTok, X, Pinterest or YouTube account is an interactive OAuth flow: the platform shows its own consent screen in a browser, and in several cases the person has to pick which Page, board or channel to grant. That cannot be driven headlessly, and shipping an API that pretends otherwise would just fail confusingly.
So: connect accounts once at getpulsarsocial.com, in the browser. After that they appear in list_accounts and in GET /api/v1/accounts, and everything else is programmable.
Tools
Tools are filtered by the key’s scopes. A read-only key does not get refused when it calls create_post — it never sees the tool at all, which stops an agent from planning around a capability it does not have.
| Tool | Does | Scope |
|---|---|---|
| list_accounts | Connected accounts and their ids | accounts:read |
| list_media | Media already in the workspace library | media:read |
| ingest_media | Fetch a public URL server-side (100 MB cap) | media:write |
| validate_post | Dry run. No writes, no quota, no live account touched | posts:read |
| create_post | Create a post across every target at once | posts:draft (+ posts:write to schedule) |
| get_post | One post, with per-target status | posts:read |
| list_posts | Keyset-paginated post list | posts:read |
| cancel_post | Stop a scheduled post, keep the record | posts:write |
| reschedule_post | Move a scheduled post | posts:write |
| delete_post | Remove a post entirely | posts:write |
| get_usage | Quota used and remaining | usage:read |
Why validate_post matters more than it sounds
Platform rules are inconsistent enough that a payload which looks obviously fine is often not. validate_post takes the exact object you would pass to create_post and reports what each target would reject — for free, and without publishing anything. Two or three validate round-trips before a create is normal.
Per-platform rules it catches
- X — 280 weighted characters. Every URL counts as 23 no matter its real length, and CJK characters and most emoji count double. A 250-character draft plus two links does not fit.
- YouTube — descriptions cap at 5,000 UTF-8 bytes, not characters. Emoji are four bytes each, so an emoji-heavy description can fail well under 5,000 characters. Titles cap at 100 characters.
- Instagram — captions cap at 2,200 characters, and
post_and_storyforks into two independent targets that succeed or fail separately. Report per-target results, not one verdict. - TikTok — needs an explicit music-usage agreement flag, which is a legal acknowledgement and has no sensible default. Apps that have not completed TikTok’s audit are restricted to
SELF_ONLYvisibility; that is TikTok’s rule and cannot be worked around. - Pinterest — a pin needs a
board_id. Valid boards come back with the account inlist_accounts. - Facebook — Pages only. Personal profiles are not publishable through the platform API.
Quota is per post, not per target
One post fanned out to six platforms is one unit of quota. Six single-platform posts are six. Put every target in a single create_post call unless the content itself genuinely differs per platform — and even then, per-target overrides are usually the better answer than separate posts.
REST API
Everything the MCP server does is also plain HTTP, with identical shapes.
curl https://getpulsarsocial.com/api/v1/accounts \
-H "Authorization: Bearer utsk_live_xxxxxxxxxxxx.your-secret-here"Conventions
- Base URL $https://getpulsarsocial.com/api/v1. Every request needs
Authorization: Bearer utsk_live_…. - Collections return
{ data: [...], next_cursor, has_more }and paginate by keyset. Follownext_cursorwhilehas_moreis true; there are no page numbers. - Errors return
{ error, code, request_id }. Quote therequest_idwhen reporting a problem. organization_idis never accepted in a request body. The key determines the workspace.- Mutating calls accept an
Idempotency-Keyheader.
Endpoints
| Endpoint | Description | Scope |
|---|---|---|
| GET/me | Key identity, workspace, plan, granted scopes | — |
| GET/usage | Quota used and remaining for the period | usage:read |
| GET/accounts | Connected social accounts and their ids | accounts:read |
| GET/media | Media library | media:read |
| POST/media/ingest | Fetch a public URL server-side. 100 MB cap | media:write |
| POST/media/upload-url | Signed URL for a direct upload. Larger files | media:write |
| POST/media/register | Register an upload once the bytes have landed | media:write |
| DELETE/media/:id | Delete a media item | media:write |
| GET/posts | List posts. Keyset paginated | posts:read |
| POST/posts/validate | Dry run. Writes nothing, no quota | posts:read |
| POST/posts | Create a post across all targets | posts:draft (+ posts:write to schedule) |
| GET/posts/:id | One post with per-target status | posts:read |
| POST/posts/:id/cancel | Stop a scheduled post, keep the record | posts:write |
| POST/posts/:id/reschedule | Move it. Cheaper than delete-and-recreate | posts:write |
| POST/posts/:id/retry | Retry failed targets | posts:write |
| DELETE/posts/:id | Delete a post | posts:write |
Validate, then create
curl https://getpulsarsocial.com/api/v1/posts/validate \
-H "Authorization: Bearer utsk_live_xxxxxxxxxxxx.your-secret-here" \
-H "Content-Type: application/json" \
-d '{
"caption": "Launching Tuesday. Here is what changed.",
"scheduled_at": "2026-08-04T09:00:00-05:00",
"targets": [
{ "social_account_id": "acc_2f9…", "kind": "post" },
{ "social_account_id": "acc_7c1…", "kind": "post" }
]
}'A clean validate returns no target errors. Then send the same body to POST /api/v1/posts with an Idempotency-Key.
draft-scoped key, that create saves a draft and does not schedule it. The response says so. Read the returned status rather than treating 200 as “scheduled”.Posting immediately
There is no post_now flag — set scheduled_at to the current time. A timestamp a few minutes in the past is accepted and treated as due now, so a client clock running slightly behind the server does not fail; older than that returns SCHEDULED_AT_IN_PAST.
Immediate does not mean instant. Media is validated, uploaded to each platform and encoded there before anything goes live, so allow seconds to minutes depending on file size. Staging also begins about 8 minutes ahead of scheduled_at, so a post scheduled sooner than that starts uploading straight away.
TikTok drafts
A TikTok target accepts options.post_mode. The default direct publishes to the profile; draftuploads the media to the creator’s TikTok inbox and posts nothing — they finish it in the TikTok app, choosing the caption, sound and privacy themselves.
draft mode TikTok ignores privacy_level and every interaction toggle, and there is no permalink because no post exists yet. The target reaching PUBLISHED means the media was delivered to the inbox, not that it is live.Media
Two ways to get bytes into a post.
- Ingest a URL — POST /media/ingest (or the
ingest_mediatool) fetches a publicly reachable URL server-side. 100 MB cap. This covers most cases. - Direct upload — POST /media/upload-url returns a signed URL, you PUT the bytes to it, then POST /media/register to add it to the library. Use this for larger files or local bytes.
Check list_media before re-ingesting an asset you already have. Media carries its own per-platform constraints — aspect ratio, duration, codec — and validate_post checks those too, which is one more reason to validate before creating.
Scopes
A key carries one of three presets. Pick the least it needs — an agent cannot exceed its key, so the scope is the actual safety boundary, not the prompt.
Look, don’t touch
Accounts, posts, media and usage, read-only. Mutating tools are not even listed.
Default · recommended
Everything read can do, plus create_post — but posts are saved as drafts for a human to approve, never scheduled.
Publishes for real
Schedules and publishes to live accounts, and can cancel, reschedule and delete. Give this out deliberately.
Presets expand into the granular scopes named in the tables above — accounts:read, media:write, posts:draft, posts:write, usage:read. GET /api/v1/me returns exactly what a key was granted.
200 from create_post on a draft key means “saved as a draft”, not “scheduled”. The response distinguishes them; anything reporting back to a human should read it.Plans & rate limits
| Plan | API access | Requests / min / key | Keys |
|---|---|---|---|
| Hobby — free | Yes | 20 | 3 |
| Starter — $19 | Yes | 60 | 10 |
| Growth — $49 | Yes | 120 | 25 |
| Scale — $99 | Yes | 300 | 100 |
Rate limits are per key per minute, so splitting traffic across two keys on the same workspace genuinely doubles headroom — up to the key cap. Exceeding a cap returns 402 PLAN_REQUIRED at key-creation time, not at request time.
A rate-limited response carries Retry-After plus RateLimit-* headers. Honour Retry-After rather than retrying on a fixed timer.
Idempotency
Every mutating call accepts an Idempotency-Key header. Use it, and reuse it on retry.
curl https://getpulsarsocial.com/api/v1/posts \
-H "Authorization: Bearer utsk_live_xxxxxxxxxxxx.your-secret-here" \
-H "Idempotency-Key: 6f1c9e2a-4b77-4a10-9d3e-8f2b5c0a71de" \
-H "Content-Type: application/json" \
-d @post.json- Generate one fresh UUID per logical post.
- On a retry — timeout, 5xx, dropped connection — send the same key again. The server returns the original result instead of creating a second post.
- Generating a new key on retry is how you double-post to a real audience. It is the most damaging mistake available here and the easiest to avoid.
- If a create failed ambiguously and no key was sent, do not blindly retry — call GET /api/v1/posts and check whether it landed.
Errors
Every error is { error, code, request_id }. 401, 402 and 403 are terminal — retrying will not resolve them.
| Status | Code | Meaning | Do |
|---|---|---|---|
| 401 | UNAUTHORIZED | Key invalid, revoked or deleted | Stop. Issue a new key in Settings → API keys. |
| 402 | PLAN_REQUIRED | Plan does not include API access, or a cap was hit | Stop. Upgrade or free up a key slot. |
| 403 | INSUFFICIENT_SCOPE | The key’s scopes do not cover this call | Stop. Do not route around it with another endpoint. |
| 404 | NOT_FOUND | Unknown id, or an id belonging to another workspace | Re-list. A stale id from earlier is the usual cause. |
| 422 | VALIDATION_FAILED | Payload rejected, with per-target detail | Fix it and run validate again. |
| 429 | RATE_LIMITED | Per-key per-minute limit exceeded | Back off for Retry-After seconds. |
Cross-workspace ids return 404 rather than 403 on purpose — a key should not be able to probe for the existence of resources it cannot reach.
Machine-readable summary
Point an agent at /docs/llms.txt for a plain-text version of this page.