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.
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.
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.
{
"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.
| Code | Status | What it means |
|---|---|---|
missing_field | 400 | A required field is absent or empty. `param` names it. |
invalid_field | 400 | A field is present and wrong. `param` names it. |
unknown_parameter | 400 | A query parameter this endpoint does not accept. Refused rather than ignored, so a typo cannot silently return unfiltered data. |
invalid_cursor | 400 | The cursor was not one we issued. Start again without one. |
malformed_json | 400 | The body is not a JSON object. |
idempotency_conflict | 409 | The 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_found | 404 | No such resource in this workspace. |
method_not_allowed | 405 | That path does not accept this method. |
missing_token | 401 | No Authorization header. |
invalid_token | 401 | Unknown, malformed or revoked. Deliberately one answer: telling them apart confirms to somebody holding an old string that it was once real. |
insufficient_role | 403 | A member token on an endpoint that needs admin. |
plan_limit_reached | 403 | The workspace plan does not allow this. |
rate_limited | 429 | Too many requests this minute. `Retry-After` says how long to wait. |
internal_error | 500 | Ours. Retry later, and quote the request id. |
Pagination
Every list takes limit (1 to 200, default 50) and cursor, and returns the same envelope.
{
"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.
| Field | Type | Description |
|---|---|---|
data | array | The rows for this page. |
has_more | boolean | True when another request with `next_cursor` returns more rows. |
next_cursor | string | null | Pass back as `?cursor=`. Null on the last page. |
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:
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.
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.
/accountsmember tokenList 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.
| Name | In | Description |
|---|---|---|
limit | query | Rows to return, 1 to 200. Defaults to 50. |
cursor | query | The `next_cursor` from a previous response. Omit for the first page. Cursors are opaque and must not be constructed by hand. |
| Field | Type | Description |
|---|---|---|
id | string | Use this as an `accountIds` entry. |
platform | string | instagram, tiktok, threads and so on. |
handle | string | null | As the platform reports it. |
display_name | string | null | The account name. |
active | boolean | Whether the customer has it switched on. |
connection_status | string | active, needs_reauth, revoked or needs_verification. The three unhealthy ones are different problems and are not collapsed. |
needs_reconnect | boolean | True for anything that is not active. Stop queueing to it. |
token_expires_at | string | null | When the platform grant lapses, if it does. |
curl -H "Authorization: Bearer $TOKEN" https://crestnote.com/api/v1/accounts
/postsmember tokenList 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`).
| Name | In | Description |
|---|---|---|
limit | query | Rows to return, 1 to 200. Defaults to 50. |
cursor | query | The `next_cursor` from a previous response. Omit for the first page. Cursors are opaque and must not be constructed by hand. |
status | query | Return only posts in this state. |
accountId | query | Return only posts for one connected account. |
| Field | Type | Description |
|---|---|---|
id | string | The post id. |
account_id | string | Which connected account it goes to. |
platform | string | The network. |
content | string | The caption or body. |
status | string | Where it is in the queue. |
scheduled_at | string | When it publishes, as an instant with a UTC offset. |
published_at | string | null | When it actually went out. Null until it has. |
url | string | null | The permalink on the platform, once published. |
ai_generated | boolean | Whether a model wrote it. Anything created through this API is true. |
curl -H "Authorization: Bearer $TOKEN" "https://crestnote.com/api/v1/posts?status=scheduled&limit=10"
/postsadmin tokenIdempotentCreate 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.
| Field | Type | Description |
|---|---|---|
accountIdsRequired | array | One or more account ids from `GET /accounts`. Each becomes its own post. |
contentRequired | string | The caption or body. |
status | string | `draft` (default) or `scheduled`. |
scheduledAt | string | Required when status is `scheduled`. Must carry a UTC offset: `2026-08-20T09:00:00Z`. A time with no zone is refused rather than guessed. |
mediaIds | array | Asset 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. |
| Field | Type | Description |
|---|---|---|
data | array | The created posts, one per account id. |
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"}'/posts/{id}member tokenRetrieve 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.
| Name | In | Description |
|---|---|---|
idRequired | path | The post id. |
| Field | Type | Description |
|---|---|---|
data | object | The post. |
curl -H "Authorization: Bearer $TOKEN" https://crestnote.com/api/v1/posts/{id}/posts/{id}admin tokenIdempotentUpdate 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.
| Name | In | Description |
|---|---|---|
idRequired | path | The post id. |
| Field | Type | Description |
|---|---|---|
content | string | Replacement text. |
scheduledAt | string | A new time, with a UTC offset. |
status | string | `draft` or `scheduled`. |
| Field | Type | Description |
|---|---|---|
data | object | The updated post. |
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"}'/posts/{id}admin tokenIdempotentCancel 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.
| Name | In | Description |
|---|---|---|
idRequired | path | The post id. |
| Field | Type | Description |
|---|---|---|
data | object | The cancelled post. |
curl -X DELETE -H "Authorization: Bearer $TOKEN" https://crestnote.com/api/v1/posts/{id}/rivalsmember tokenList 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.
| Name | In | Description |
|---|---|---|
limit | query | Rows to return, 1 to 200. Defaults to 50. |
cursor | query | The `next_cursor` from a previous response. Omit for the first page. Cursors are opaque and must not be constructed by hand. |
platform | query | Only competitors on one network. |
| Field | Type | Description |
|---|---|---|
id | string | Use as `profileId` on `/rivals/posts`. |
platform | string | The network. |
handle | string | As the customer entered it. |
display_name | string | null | As the platform reports it. |
profile_url | string | null | The permalink. |
tracking_status | string | active, paused, pending or failing. |
last_error | string | null | Why collection is failing, when it is. |
last_collected_at | string | null | The last successful read. |
curl -H "Authorization: Bearer $TOKEN" https://crestnote.com/api/v1/rivals
/rivals/postsmember tokenList 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.
| Name | In | Description |
|---|---|---|
limit | query | Rows to return, 1 to 200. Defaults to 50. |
cursor | query | The `next_cursor` from a previous response. Omit for the first page. Cursors are opaque and must not be constructed by hand. |
profileId | query | Only posts by one competitor, from `GET /rivals`. |
| Field | Type | Description |
|---|---|---|
id | string | Our id for the row. |
profile_id | string | Which competitor published it. |
url | string | The permalink. Always present. |
posted_at | string | When they published it. |
title | string | null | Null after the retention purge. |
excerpt | string | null | Null after the retention purge. |
likes | integer | null | Null when the platform did not report it. |
comments | integer | null | Null when unreadable. |
shares | integer | null | Null when unreadable. |
views | integer | null | Null when unreadable. |
curl -H "Authorization: Bearer $TOKEN" "https://crestnote.com/api/v1/rivals/posts?limit=25"
/analyticsmember tokenRead 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.
| Name | In | Description |
|---|---|---|
seriesRequired | query | `followers` for daily audience counts, `posts` for per-post performance.followers | posts |
platform | query | Only one network. |
limit | query | Rows to return, 1 to 200. Defaults to 50. |
cursor | query | The `next_cursor` from a previous response. Omit for the first page. Cursors are opaque and must not be constructed by hand. |
| Field | Type | Description |
|---|---|---|
platform | string | The network. |
date | string | followers only: the day of the reading. |
followers | integer | null | followers only. |
post_id | string | posts only: which post. |
age_hours | integer | posts only: how old it was when read. |
likes | integer | null | posts only. Null when unreadable. |
views | integer | null | posts only. Null when unreadable. |
curl -H "Authorization: Bearer $TOKEN" "https://crestnote.com/api/v1/analytics?series=followers&limit=60"
/mediamember tokenList media
The workspace library. Upload in two calls: `POST /media` for a signed URL, then `POST /media/register` once the bytes are in place.
| Name | In | Description |
|---|---|---|
limit | query | Rows to return, 1 to 200. Defaults to 50. |
cursor | query | The `next_cursor` from a previous response. Omit for the first page. Cursors are opaque and must not be constructed by hand. |
| Field | Type | Description |
|---|---|---|
id | string | The asset id. This is what `mediaIds` takes. |
kind | string | image or video. |
url | string | Where it is served from. |
mime_type | string | As uploaded. |
width | integer | null | Pixels, when known. |
height | integer | null | Pixels, when known. |
byte_size | integer | null | Bytes, when known. |
filename | string | null | The original name. |
alt_text | string | null | What is in the image, for somebody who cannot see it. Null means nobody has described it; an empty string means it was judged decorative. |
curl -H "Authorization: Bearer $TOKEN" https://crestnote.com/api/v1/media
/mediaadmin tokenIdempotentStart 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`.
| Field | Type | Description |
|---|---|---|
filename | string | The original name. Used to work out the type when `contentType` is absent, which is what browsers do for some files. |
contentType | string | The mime type, for example `image/jpeg`. An unsupported type is refused here. |
byteSize | integer | The 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. |
| Field | Type | Description |
|---|---|---|
storagePath | string | Pass this back to `/media/register`. |
signedUrl | string | PUT the file here. |
token | string | The upload token, for clients that take one. |
kind | string | image or video, decided from the type. |
contentType | string | The normalised mime type. |
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}'/media/registeradmin tokenIdempotentFinish 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.
| Field | Type | Description |
|---|---|---|
storagePathRequired | string | The `storagePath` from `POST /media`. |
contentTypeRequired | string | The same type declared at step one. It must agree with the path. |
altText | string | What 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. |
width | integer | Pixels. Improves how some networks frame it. |
height | integer | Pixels. |
durationSeconds | integer | For a video. |
folderId | string | A folder in this workspace to file it under. |
| Field | Type | Description |
|---|---|---|
data | object | The created asset. |
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"}'/platform-limitsmember tokenWhat 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.
| Field | Type | Description |
|---|---|---|
platform | string | The id used everywhere else in this API. |
label | string | What a person calls it. |
char_limit | integer | The caption ceiling. |
char_unit | string | `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. |
publishes | boolean | False when a post here has to be made by hand. `publish_note` says why. |
publish_note | string | null | Why publishing is unavailable. Null when it is available. |
media | object | What a post there accepts: `max_images`, `min_images`, `max_videos`, `text_only`, and `accepted_image_types` where the network gates on it. |
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.
{
"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.
| Tool | Same as | Needs |
|---|---|---|
list_accounts | GET /accounts | member token |
list_posts | GET /posts | member token |
create_post | POST /posts | admin token |
update_post | PATCH /posts/{id} | admin token |
cancel_post | DELETE /posts/{id} | admin token |
list_rivals | GET /rivals | member token |
list_rival_posts | GET /rivals/posts | member token |
get_analytics | GET /analytics | member token |
list_media | GET /media | member token |