API referencev1

One workspace, over HTTP.

Read and write the queue, the connected accounts, the competitors you track and every metric collected for you. Everything is JSON, every list is cursor-paged, and every error carries a stable code you can branch on.

Getting started

Mint a token at Settings, API inside the product, then ask the API what it can see. If this returns your accounts, everything else on this page will work.

Your first request
curl -H "Authorization: Bearer $TOKEN" https://crestnote.com/api/v1/accounts

Every response is JSON. Success and failure are distinguishable by shape alone: a failure has a top-level error object and a success never does, so a client can write if ('error' in body) and never consult a status code. That matters more than it sounds, because a proxy or a corporate middlebox can rewrite a status and none of them rewrites a body.

GET https://crestnote.com/api/v1 with no token returns the list of endpoints, so you can check the base URL is right before you have a credential.

Authentication

A bearer token, in a header. Nothing else is accepted, and in particular there is no ?token= query parameter: a credential in a URL lands in every access log, every proxy and every browser history between you and us, and the convenience of putting it there is exactly why people do it by accident.

The header
Authorization: Bearer sk_social_xxxxxxxxxxxx

A token belongs to one workspace and carries one role. A member token reads. An admin token also writes: creating, changing and cancelling posts. A token can never outrank the person who created it, so a member of a workspace cannot mint an admin token.

The plaintext is shown once, when it is created, and is never recoverable: we store a hash of it and nothing else. If one leaks, revoke it. Revocation takes effect on the next request.

A missing token, an unknown one and a revoked one are all answered with the same 401 and the same sentence. That is deliberate: telling them apart confirms to somebody holding a string from an old log that it was once real.

Errors

Every failure has the same shape. Branch on code, which is stable forever. Never match on message, which is written for a human reading a log at 3am and is improved whenever a better sentence exists.

A refusal
{
  "error": {
    "type": "invalid_request_error",
    "code": "missing_field",
    "message": "A post needs content.",
    "param": "content",
    "request_id": "req_9f2c41ab8e5d4c7a"
  }
}

type says what to do rather than what went wrong, which is the distinction a retry loop needs and a status code does not give you: invalid_request_error and authentication_error mean stop and fix it, api_error means waiting is reasonable.

request_id is on every response, failures and successes, in the body and in the X-Request-Id header. Quote it and a support conversation is one message long, because it is in our log beside whatever produced it.

CodeStatusWhat it means
missing_field400A required field is absent or empty. `param` names it.
invalid_field400A field is present and wrong. `param` names it.
unknown_parameter400A query parameter this endpoint does not accept. Refused rather than ignored, so a typo cannot silently return unfiltered data.
invalid_cursor400The cursor was not one we issued. Start again without one.
malformed_json400The body is not a JSON object.
idempotency_conflict409The same Idempotency-Key with a different body, or a request with that key still in flight. Use a new key, or retry in a moment.
not_found404No such resource in this workspace.
method_not_allowed405That path does not accept this method.
missing_token401No Authorization header.
invalid_token401Unknown, malformed or revoked. Deliberately one answer: telling them apart confirms to somebody holding an old string that it was once real.
insufficient_role403A member token on an endpoint that needs admin.
plan_limit_reached403The workspace plan does not allow this.
rate_limited429Too many requests this minute. `Retry-After` says how long to wait.
internal_error500Ours. Retry later, and quote the request id.

Pagination

Every list takes limit (1 to 200, default 50) and cursor, and returns the same envelope.

A page
{
  "data": [ ... ],
  "has_more": true,
  "next_cursor": "MjAyNi0wOC0xN1QwOTowMDowMFogYTFiMg"
}

Read until has_more is false, passing the previous next_cursor back as ?cursor=. Cursors are opaque; decoding one reveals nothing you were not just sent, but constructing one by hand is not supported and the format will change.

The envelope
FieldTypeDescription
dataarrayThe rows for this page.
has_morebooleanTrue when another request with `next_cursor` returns more rows.
next_cursorstring | nullPass back as `?cursor=`. Null on the last page.
Reading everything
cursor=""
while : ; do
  page=$(curl -s -H "Authorization: Bearer $TOKEN" "https://crestnote.com/api/v1/posts?limit=200&cursor=$cursor")
  echo "$page" | jq '.data[]'
  [ "$(echo "$page" | jq -r '.has_more')" = "true" ] || break
  cursor=$(echo "$page" | jq -r '.next_cursor')
done

It is a cursor and not ?page= because these lists are ordered newest first and rows arrive at the front of them constantly: a cron writes metrics every few minutes and the queue writes posts all day. With offsets, a caller reading page two after three new rows arrived sees three rows they already read, and one reading while rows are deleted skips some entirely.

Rate limits

Per token, per minute: 300 reads and 60 writes. Writes are lower because a runaway write loop publishes to a real audience, and the thing being protected there is your account rather than our database.

Every response carries your budget, so a well-behaved client never has to guess:

On every response
RateLimit-Limit: 300
RateLimit-Remaining: 287
RateLimit-Reset: 34

Over the limit is a 429 with Retry-After in seconds. If one integration needs more headroom, mint it a second token: the limit is keyed to the token, so an export and a live agent on the same workspace do not compete, and revoking a runaway one restores everybody else’s budget immediately.

Idempotency

Send Idempotency-Key on every write. Any unique string per logical operation will do, and a UUID is the usual choice.

A safe create
curl -X POST https://crestnote.com/api/v1/posts \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"accountIds":["..."],"content":"Hello"}'

A repeat of the same key returns the first response verbatim, with Idempotent-Replay: true on it, and does no work. Keys live 24 hours.

This is not politeness. Your client retries a request whose response was lost, because from where it is standing the request never happened, and the request may well have succeeded. Without a key, that retry publishes the same post twice and your followers are the ones who notice.

Reusing a key with a different body is refused with idempotency_conflict rather than replayed. Replaying would hand you the first post’s id for a request that asked for something else, and you would believe the second one was queued.

Missing numbers are null, never zero

Worth its own section because it will change how you write your client. Where a platform did not report a metric, the field is null. It is never coalesced to 0 on the way in and never on the way out.

Some networks never report some metrics at all, and every network occasionally fails to answer. A zero in place of either is a fabricated measurement, and the conclusion drawn from it (“engagement fell to nothing on the 14th”) is one nobody made. Chart a null as a gap in the line and average over the readings you have.

The same rule applies to competitor text: title and excerpt on /rivals/posts are null on older rows, because the counts are kept while the platform’s words about somebody else are purged on a retention clock. An absent excerpt is a normal row and never a missing post. The permalink is always there.

Versioning

The version is in the path, and v1 is the only one. Inside it, these are additive and can happen at any time: a new endpoint, a new field on a response, a new optional parameter, a new value in an enum, a new error code. Write a client that ignores fields it does not recognise.

These would be a new version rather than a change to this one: removing or renaming a field, changing the type of one, removing an endpoint, changing what an existing code means, or making an optional parameter required.

If v2 is ever needed, v1 keeps working for at least twelve months after it is announced, and the announcement goes to the email on every workspace using a token rather than only onto the changelog.

Endpoints

13 of them. Every path below is relative to https://crestnote.com/api/v1, every one needs a token, and the role each needs is on its heading.

GET/accountsmember token

List connected accounts

The social accounts this workspace publishes to. This is the call every other one depends on: a post needs account ids and nothing else in this API hands them out. `needs_reconnect` is true for any account that is not healthy, so an integration can stop queueing to a channel a human has to fix.

Parameters
NameInDescription
limitqueryRows to return, 1 to 200. Defaults to 50.
cursorqueryThe `next_cursor` from a previous response. Omit for the first page. Cursors are opaque and must not be constructed by hand.
Each row in data
FieldTypeDescription
idstringUse this as an `accountIds` entry.
platformstringinstagram, tiktok, threads and so on.
handlestring | nullAs the platform reports it.
display_namestring | nullThe account name.
activebooleanWhether the customer has it switched on.
connection_statusstringactive, needs_reauth, revoked or needs_verification. The three unhealthy ones are different problems and are not collapsed.
needs_reconnectbooleanTrue for anything that is not active. Stop queueing to it.
token_expires_atstring | nullWhen the platform grant lapses, if it does.
Example
curl -H "Authorization: Bearer $TOKEN" https://crestnote.com/api/v1/accounts
GET/postsmember token

List posts

The queue and its history, newest scheduled time first. Filter by status to find what is waiting on a human (`awaiting_approval`), what is about to go out (`scheduled`), or what already did (`published`).

Parameters
NameInDescription
limitqueryRows to return, 1 to 200. Defaults to 50.
cursorqueryThe `next_cursor` from a previous response. Omit for the first page. Cursors are opaque and must not be constructed by hand.
statusqueryReturn only posts in this state.
accountIdqueryReturn only posts for one connected account.
Each row in data
FieldTypeDescription
idstringThe post id.
account_idstringWhich connected account it goes to.
platformstringThe network.
contentstringThe caption or body.
statusstringWhere it is in the queue.
scheduled_atstringWhen it publishes, as an instant with a UTC offset.
published_atstring | nullWhen it actually went out. Null until it has.
urlstring | nullThe permalink on the platform, once published.
ai_generatedbooleanWhether a model wrote it. Anything created through this API is true.
Example
curl -H "Authorization: Bearer $TOKEN" "https://crestnote.com/api/v1/posts?status=scheduled&limit=10"
POST/postsadmin tokenIdempotent

Create a post

Writes one post per account named, so a single call crossposts, with an optional ordered carousel from the library. It defaults to `draft`, deliberately: what this product sells is a pre-filled queue a human approves, so a caller that wants it queued has to say so. Send an `Idempotency-Key` header; a retry after a timeout would otherwise publish twice.

Body
FieldTypeDescription
accountIdsRequiredarrayOne or more account ids from `GET /accounts`. Each becomes its own post.
contentRequiredstringThe caption or body.
statusstring`draft` (default) or `scheduled`.
scheduledAtstringRequired when status is `scheduled`. Must carry a UTC offset: `2026-08-20T09:00:00Z`. A time with no zone is refused rather than guessed.
mediaIdsarrayAsset ids from `GET /media`, in the order they should appear. Checked against every platform in the call before anything is written, so a five-image post to X is refused here rather than at publishing time. Instagram and TikTok cannot publish text alone, so a post to either needs this.
Returns
FieldTypeDescription
dataarrayThe created posts, one per account id.
Example
curl -X POST https://crestnote.com/api/v1/posts \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"accountIds":["acc_..."],"content":"Hello","status":"scheduled","scheduledAt":"2026-09-01T09:00:00Z"}'
GET/posts/{id}member token

Retrieve a post

One post by id. 404 for an id in another workspace, which is the same answer as an id that does not exist.

Parameters
NameInDescription
idRequiredpathThe post id.
Returns
FieldTypeDescription
dataobjectThe post.
Example
curl -H "Authorization: Bearer $TOKEN" https://crestnote.com/api/v1/posts/{id}
PATCH/posts/{id}admin tokenIdempotent

Update a post

Change the content, the time, or whether it is queued. Only a post that has not gone out can be edited. A published post is refused, because editing our row would change our record of what was published without changing what is on the platform.

Parameters
NameInDescription
idRequiredpathThe post id.
Body
FieldTypeDescription
contentstringReplacement text.
scheduledAtstringA new time, with a UTC offset.
statusstring`draft` or `scheduled`.
Returns
FieldTypeDescription
dataobjectThe updated post.
Example
curl -X PATCH https://crestnote.com/api/v1/posts/{id} \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"scheduledAt":"2026-09-02T18:30:00Z"}'
DELETE/posts/{id}admin tokenIdempotent

Cancel a post

Sets the post to `cancelled`. It is a soft cancel rather than a delete: the metrics collector and the recycler both reason about history, and a hole where a post used to be is indistinguishable from a post that never existed. Cancelling an already cancelled post returns it unchanged, so a retry is safe.

Parameters
NameInDescription
idRequiredpathThe post id.
Returns
FieldTypeDescription
dataobjectThe cancelled post.
Example
curl -X DELETE -H "Authorization: Bearer $TOKEN" https://crestnote.com/api/v1/posts/{id}
GET/rivalsmember token

List tracked competitors

The competitor profiles this workspace watches. `tracking_status` is worth reading rather than ignoring: a `pending` profile has never resolved and has no history behind it, so a chart drawn from it is showing the absence of a collector.

Parameters
NameInDescription
limitqueryRows to return, 1 to 200. Defaults to 50.
cursorqueryThe `next_cursor` from a previous response. Omit for the first page. Cursors are opaque and must not be constructed by hand.
platformqueryOnly competitors on one network.
Each row in data
FieldTypeDescription
idstringUse as `profileId` on `/rivals/posts`.
platformstringThe network.
handlestringAs the customer entered it.
display_namestring | nullAs the platform reports it.
profile_urlstring | nullThe permalink.
tracking_statusstringactive, paused, pending or failing.
last_errorstring | nullWhy collection is failing, when it is.
last_collected_atstring | nullThe last successful read.
Example
curl -H "Authorization: Bearer $TOKEN" https://crestnote.com/api/v1/rivals
GET/rivals/postsmember token

List competitor posts

What tracked competitors published, with the counts we were able to read. `title` and `excerpt` are null on rows older than the platform retention window: the counts are kept and the text is purged, so an absent excerpt is a normal row and never a missing post.

Parameters
NameInDescription
limitqueryRows to return, 1 to 200. Defaults to 50.
cursorqueryThe `next_cursor` from a previous response. Omit for the first page. Cursors are opaque and must not be constructed by hand.
profileIdqueryOnly posts by one competitor, from `GET /rivals`.
Each row in data
FieldTypeDescription
idstringOur id for the row.
profile_idstringWhich competitor published it.
urlstringThe permalink. Always present.
posted_atstringWhen they published it.
titlestring | nullNull after the retention purge.
excerptstring | nullNull after the retention purge.
likesinteger | nullNull when the platform did not report it.
commentsinteger | nullNull when unreadable.
sharesinteger | nullNull when unreadable.
viewsinteger | nullNull when unreadable.
Example
curl -H "Authorization: Bearer $TOKEN" "https://crestnote.com/api/v1/rivals/posts?limit=25"
GET/analyticsmember token

Read collected metrics

Two series behind one path, chosen with `?series=`. They used to come back together under one limit, which meant a caller who wanted a year of follower history also got a year of post metrics and could page through neither. Every number is exactly as collected: a metric the platform did not return is null and is never a zero.

Parameters
NameInDescription
seriesRequiredquery`followers` for daily audience counts, `posts` for per-post performance.followers | posts
platformqueryOnly one network.
limitqueryRows to return, 1 to 200. Defaults to 50.
cursorqueryThe `next_cursor` from a previous response. Omit for the first page. Cursors are opaque and must not be constructed by hand.
Each row in data
FieldTypeDescription
platformstringThe network.
datestringfollowers only: the day of the reading.
followersinteger | nullfollowers only.
post_idstringposts only: which post.
age_hoursintegerposts only: how old it was when read.
likesinteger | nullposts only. Null when unreadable.
viewsinteger | nullposts only. Null when unreadable.
Example
curl -H "Authorization: Bearer $TOKEN" "https://crestnote.com/api/v1/analytics?series=followers&limit=60"
GET/mediamember token

List media

The workspace library. Upload in two calls: `POST /media` for a signed URL, then `POST /media/register` once the bytes are in place.

Parameters
NameInDescription
limitqueryRows to return, 1 to 200. Defaults to 50.
cursorqueryThe `next_cursor` from a previous response. Omit for the first page. Cursors are opaque and must not be constructed by hand.
Each row in data
FieldTypeDescription
idstringThe asset id. This is what `mediaIds` takes.
kindstringimage or video.
urlstringWhere it is served from.
mime_typestringAs uploaded.
widthinteger | nullPixels, when known.
heightinteger | nullPixels, when known.
byte_sizeinteger | nullBytes, when known.
filenamestring | nullThe original name.
alt_textstring | nullWhat is in the image, for somebody who cannot see it. Null means nobody has described it; an empty string means it was judged decorative.
Example
curl -H "Authorization: Bearer $TOKEN" https://crestnote.com/api/v1/media
POST/mediaadmin tokenIdempotent

Start a media upload

Returns a signed URL to PUT the file to. The bytes never pass through this API, which is what lets you upload a 200 MB video: a request body here is capped at 4.5 MB by the platform, and a binary body would be corrupted by the idempotency fingerprint before any handler saw it. Follow with `POST /media/register`.

Body
FieldTypeDescription
filenamestringThe original name. Used to work out the type when `contentType` is absent, which is what browsers do for some files.
contentTypestringThe mime type, for example `image/jpeg`. An unsupported type is refused here.
byteSizeintegerThe file size. Checked against the limit for its kind before a URL is minted, so an oversized file is refused before it is uploaded rather than after.
Returns
FieldTypeDescription
storagePathstringPass this back to `/media/register`.
signedUrlstringPUT the file here.
tokenstringThe upload token, for clients that take one.
kindstringimage or video, decided from the type.
contentTypestringThe normalised mime type.
Example
curl -X POST https://crestnote.com/api/v1/media \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"filename":"launch.jpg","contentType":"image/jpeg","byteSize":482114}'
POST/media/registeradmin tokenIdempotent

Finish a media upload

Records the uploaded object as an asset, and returns the id `mediaIds` takes. The object is checked for real existence in storage first, so a PUT that failed silently cannot leave a library row whose image is missing. Registering a path that is already in the library answers 409.

Body
FieldTypeDescription
storagePathRequiredstringThe `storagePath` from `POST /media`.
contentTypeRequiredstringThe same type declared at step one. It must agree with the path.
altTextstringWhat is in the image, for somebody who cannot see it. Worth sending: some networks require the field, and one that arrives without a description publishes an empty one.
widthintegerPixels. Improves how some networks frame it.
heightintegerPixels.
durationSecondsintegerFor a video.
folderIdstringA folder in this workspace to file it under.
Returns
FieldTypeDescription
dataobjectThe created asset.
Example
curl -X POST https://crestnote.com/api/v1/media/register \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"storagePath":"images/....jpg","contentType":"image/jpeg","altText":"A cortado on a wooden counter"}'
GET/platform-limitsmember token

What each network accepts

Caption ceilings, the unit each network counts them in, how much media each takes, and whether we can publish there at all. Read this rather than hardcoding it: these are somebody else's numbers, they move, and what comes back is what the publish path is currently enforcing rather than a summary of it.

Returns
FieldTypeDescription
platformstringThe id used everywhere else in this API.
labelstringWhat a person calls it.
char_limitintegerThe caption ceiling.
char_unitstring`characters` or `graphemes`. Bluesky counts graphemes, so a caption of 300 emoji is 300 there and 3,300 to `String.length`. Measure in the unit named here.
publishesbooleanFalse when a post here has to be made by hand. `publish_note` says why.
publish_notestring | nullWhy publishing is unavailable. Null when it is available.
mediaobjectWhat a post there accepts: `max_images`, `min_images`, `max_videos`, `text_only`, and `accepted_image_types` where the network gates on it.
Example
curl https://crestnote.com/api/v1/platform-limits

MCP server

The same operations, spoken to by an agent instead of a script. Point any MCP client at the endpoint with the same token in the same header.

Client configuration
{
  "mcpServers": {
    "social": {
      "url": "https://crestnote.com/api/mcp",
      "headers": { "Authorization": "Bearer $TOKEN" }
    }
  }
}

It speaks JSON-RPC 2.0 over a single POST: initialize, tools/list and tools/call. There is deliberately no SSE stream, no notifications, no sampling and no resources, because each needs server state a serverless deployment does not have, and a capability claimed and then unanswered reads as a broken server rather than a smaller one.

Each tool calls the same function its REST endpoint calls, so an agent and a script cannot see different rows of the same workspace.

ToolSame asNeeds
list_accountsGET /accountsmember token
list_postsGET /postsmember token
create_postPOST /postsadmin token
update_postPATCH /posts/{id}admin token
cancel_postDELETE /posts/{id}admin token
list_rivalsGET /rivalsmember token
list_rival_postsGET /rivals/postsmember token
get_analyticsGET /analyticsmember token
list_mediaGET /mediamember token