Developers
REST API
Create drafts, request reviews, schedule and publish posts, and read results from your own tools with the Distinctful REST API on Scale.
Overview
The REST API connects your own tools to the same drafts, reviews, calendar, and results your team uses in the app. Use it to push drafts in from a CMS or spreadsheet, schedule approved work from Zapier or Make, or pull post results into a client report. The API is part of the Scale plan.
Every request goes to this base address and returns JSON:
https://app.distinctful.com/api/v1Call the API from a server, a script, or an automation tool. Browsers are refused, so a key never ends up in a web page.
Prefer to work from an AI tool such as Claude or ChatGPT? Use the MCP server instead. It signs in with OAuth and needs no key.
Authentication
Create a key in Distinctful under Settings, Integrations, API keys. The key starts with dfk_ and is shown once, so store it in your tool’s secret settings right away. Send it on every request:
Authorization: Bearer dfk_your_keyEach person can hold up to 10 keys in a Distinctful. The owner sees and can revoke every key; a teammate sees only their own. A revoked key stops working on its next request. Distinctful stores only a hash of each key, so a lost key cannot be shown again; revoke it and create a new one.
Roles and approvals
A key acts as the person who created it, with the role they hold at the moment of each request. Change a teammate’s role or remove them, and their keys follow on the next call.
- Editors can create drafts and request review. They can schedule or publish only a draft the owner or a Lead approved, only while its text is unchanged since that approval, and only to the platforms the approval covered.
- Leads and the owner can schedule and publish any draft, decide reviews for the team, share and revoke client review links, and cancel scheduled posts.
- When the workspace requires client approval, no key can skip it. Posting without the client’s sign-off is a decision a person makes in the app.
Quickstart
List the accounts a draft can go to. Each one has an accountId you use when scheduling.
curl https://app.distinctful.com/api/v1/accounts \
-H "Authorization: Bearer $DISTINCTFUL_API_KEY"Check publishingStatus before you schedule. AVAILABLE means the account can post now; ACTION_REQUIRED means someone needs to reconnect it in Distinctful.
Endpoints
Paths are relative to the base URL.
GET/accountsConnected social accounts and whether each can post right now.GET/draftsDrafts, most recently changed first, with review status.POST/draftsCreate a draft. A planned date puts it on the calendar; it never publishes.GET/drafts/{id}One draft with its full text, media, posts, and review status.PATCH/drafts/{id}Change the title, content, thread posts, or destinations.DELETE/drafts/{id}Delete a draft that has nothing published, scheduled, or linked to a deal.POST/drafts/{id}/mediaStart an image or video upload.POST/drafts/{id}/media/{mediaId}/completeAttach the uploaded file once its bytes are stored.DELETE/drafts/{id}/media/{mediaId}Remove media, or cancel a video upload.GET/drafts/{id}/reviewThe latest review round, its history, and the client link.POST/drafts/{id}/reviewRequest review: freeze the current text into a new review round.POST/drafts/{id}/review/decisionApprove the open round for the team, or ask for changes. Owner or Lead.POST/drafts/{id}/scheduleSchedule the saved draft on accounts at a time.POST/drafts/{id}/publishPublish the saved draft on accounts now.POST/review-linksShare drafts with a client on a review link. Owner or Lead.DELETE/review-links/{id}Revoke a review link. Owner or Lead.GET/postsPublished posts with the metrics each platform reports, newest first.POST/posts/{id}/cancelCancel a scheduled post. Owner or Lead.GET/analytics/summaryReach, interactions, engagement rate, posts shipped, and followers for 30 or 90 days, by platform and by day.GET/eventsReview steps and publish outcomes in the order they happened.Creating drafts
A draft needs a title and a type: POST for a single post, or THREAD with threadPosts for a thread of up to 25 parts. Add content for the post text, destinationPlatforms for where it is meant to go, and scheduledDate to place it on the calendar. A planned date only plans; nothing posts until you schedule or publish it.
curl -X POST https://app.distinctful.com/api/v1/drafts \
-H "Authorization: Bearer $DISTINCTFUL_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: launch-post-1" \
-d '{
"title": "Launch post",
"type": "POST",
"content": "We just shipped client approvals that never need an account.",
"destinationPlatforms": ["LINKEDIN", "THREADS"]
}'{
"data": {
"id": "6f1c2a8e-4b0d-4c55-9a43-2f1e7c9b5d10",
"title": "Launch post",
"type": "POST",
"status": "NEXT_UP",
"scheduledDate": null,
"destinationPlatforms": ["LINKEDIN", "THREADS"],
"updatedAt": "2026-11-07T15:04:05.000Z",
"content": "We just shipped client approvals that never need an account.",
"review": null,
"media": [],
"posts": []
}
}The Idempotency-Key header is optional here. Send it and a retry with the same key returns the first draft instead of creating a second one.
Editing and deleting
PATCH /drafts/{id} changes only the fields you send: title, content, threadPosts, and destinationPlatforms. Any other field is refused. On a thread, the text lives in threadPosts. Read them from GET /drafts/{id}, change what you need, and send the whole list back with each id kept; a thread post you leave out loses its media.
curl -X PATCH https://app.distinctful.com/api/v1/drafts/$DRAFT_ID \
-H "Authorization: Bearer $DISTINCTFUL_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "content": "We just shipped client approvals. No account needed." }'An edit after approval does not carry the approval with it: the draft reads EDITED_SINCE_REVIEW, and an Editor needs a new approval before scheduling it.
DELETE /drafts/{id} deletes a draft. It refuses with 409 when the draft has a published post (details.code is published), is linked to a deal (deal_linked), or still has a post scheduled; cancel that post first. Deleting a draft that is already gone succeeds, so a retry is safe.
Images and video
Uploads go straight to storage in three steps: start, send the bytes, complete. A draft holds up to 10 files. Images are one PUT. Send every header the start call returns, exactly as given.
# 1. Start: say what you are sending
curl -X POST https://app.distinctful.com/api/v1/drafts/$DRAFT_ID/media \
-H "Authorization: Bearer $DISTINCTFUL_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "fileName": "launch.png", "mimeType": "image/png", "sizeBytes": 482113 }'
# 2. PUT the file to data.uploadUrl with every header in data.headers
curl -X PUT "$UPLOAD_URL" -H "Content-Type: image/png" --data-binary @launch.png
# 3. Complete: the file joins the draft
curl -X POST https://app.distinctful.com/api/v1/drafts/$DRAFT_ID/media/$MEDIA_ID/complete \
-H "Authorization: Bearer $DISTINCTFUL_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "altText": "The new approvals screen" }'Video is resumable. Send the file’s sha256 when you start. The response’s uploadMethod says what to do: parts lists byte ranges to PUT, tus gives a tus 1.0 endpoint, and complete means every byte is stored. After each batch of parts, or if a URL expires, call start again with the same uploadId until it answers complete. Then complete with the video’s width, height, and durationMs.
On a thread, pass threadPostId when you start, to attach the file to that post. Completing again returns the same media. To remove media, or cancel a video upload in progress, call DELETE /drafts/{id}/media/{mediaId}.
Reviews and client links
POST /drafts/{id}/review freezes the draft into a new round. GET on the same path returns the latest round, what each reviewer said, and the client link the draft was last shared on.
The owner or a Lead decides the open round with /review/decision. A note is required when asking for changes. Pass the reviewId you read, and the call is refused with not_open if a newer round has opened since.
curl -X POST https://app.distinctful.com/api/v1/drafts/$DRAFT_ID/review/decision \
-H "Authorization: Bearer $DISTINCTFUL_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "decision": "CHANGES_REQUESTED", "note": "Lead with the client quote.", "reviewId": "$REVIEW_ID" }'To get a client’s sign-off, share up to 25 drafts on one review link. The client reviews without an account.
curl -X POST https://app.distinctful.com/api/v1/review-links \
-H "Authorization: Bearer $DISTINCTFUL_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: week-46-review" \
-d '{ "draftIds": ["$DRAFT_ID"], "label": "Acme, week 46" }'Review links never expire. Anyone holding the url can see the drafts and answer for the client until you revoke it with DELETE /review-links/{id}. Send it only to the client, and revoke it when the review is done. Editors’ keys never see link URLs.
Scheduling and publishing
Schedule and publish always post the draft’s saved text, exactly as the composer would. Name up to 10 accountIds from /accounts. Both calls require an Idempotency-Key: use a new key for each new post and the same key when you retry, and you will never post twice.
curl -X POST https://app.distinctful.com/api/v1/drafts/$DRAFT_ID/schedule \
-H "Authorization: Bearer $DISTINCTFUL_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"accountIds": ["LINKEDIN_ACCOUNT_ID", "THREADS_ACCOUNT_ID"],
"scheduledFor": "2026-11-10T14:00:00-05:00"
}'{
"data": [
{
"integrationId": "LINKEDIN_ACCOUNT_ID",
"publishRecordId": "0b8f…",
"status": "scheduled",
"scheduledFor": "2026-11-10T19:00:00.000Z",
"executionMode": "inngest"
}
]
}Each account gets its own result, so one disconnected account does not block the rest. A publish-now call returns an outcome of published, partially_published, queued, or all_failed, with a result per account. Queued posts finish in the background and show up in events.
When a draft still needs approval, the call is refused with 409 and details.code says why: approval_required when an Editor’s key needs the team’s (and, where required, the client’s) approval first, or client_approval_required when the client has not signed off yet. The message explains what to do next.
{
"error": {
"code": "CONFLICT",
"message": "This post needs approval from the owner or a Lead before an Editor can schedule it. Request review first.",
"details": { "code": "approval_required" }
}
}Each draft lists its posts, with an id per account. The owner or a Lead can cancel a scheduled one with POST /posts/{id}/cancel. Cancelling again answers the same. A post already handed to its platform answers 409 with already_publishing.
Polling for events
Poll /events to react when a draft is approved, a client asks for changes, or a post goes live. Pass after on the first call, then the nextCursor each response returns. Store the cursor between runs and you will pick up exactly where you stopped.
# First call: everything since a time you choose
curl "https://app.distinctful.com/api/v1/events?after=2026-11-07T00:00:00Z" \
-H "Authorization: Bearer $DISTINCTFUL_API_KEY"
# Every call after: pass back the cursor you were given
curl "https://app.distinctful.com/api/v1/events?cursor=$NEXT_CURSOR" \
-H "Authorization: Bearer $DISTINCTFUL_API_KEY"{
"data": {
"data": [
{
"id": "c7d1…",
"type": "review.approved",
"at": "2026-11-07T16:20:11.000Z",
"draftId": "6f1c2a8e-…",
"reviewerName": "Jordan"
},
{
"id": "post.published:9a2e…:2026-11-10T19:00:04.000Z",
"type": "post.published",
"at": "2026-11-10T19:00:04.000Z",
"draftId": "6f1c2a8e-…",
"postId": "9a2e…",
"platform": "LINKEDIN",
"url": "https://www.linkedin.com/feed/update/…"
}
],
"nextCursor": "eyJyZXZpZXdzIjpb…",
"hasMore": false
}
}Events from the last 10 seconds are held for the next poll, so nothing written late is skipped. Delivery is at least once, so ignore an event id you have already handled. When hasMore is true, the next page is ready now; otherwise poll again in a minute or so.
review.requestedSomeone asked for review.review.sharedThe draft went to a client on a review link.review.commentedA teammate or client left a comment.review.changes_requestedThe owner, a Lead, or the client asked for changes.review.approvedThe owner, a Lead, or the client approved.review.overriddenAn owner or Lead posted without the client approval the workspace requires.review.scheduledThe approved draft was scheduled.post.publishedA post went live. url links to it.post.failedA post could not be published.Pagination
/drafts and /posts return 50 items a page. When nextCursor is not null, pass it back as cursor for the next page. Cursors are opaque: send them back unchanged.
Rate limits
Limits are counted per person: every key one person holds shares them, and teammates each get their own.
Reads60 a minuteEvery GET.Writes20 a minuteCreating, editing, and deleting drafts, reviews, and review links.Media60 a minuteStarting, completing, and removing uploads.Posts10 a minuteSchedule, publish, and cancel calls.Every response after your key is checked says where you stand:
RateLimit-LimitRequests this budget allows a minute.RateLimit-RemainingRequests left in the current window.RateLimit-ResetSeconds until the oldest counted request leaves the window and frees a slot.Retry-AfterOn 429 only: seconds to wait before retrying.Over a limit, you get 429. Repeated requests with invalid keys from one network address are blocked for a while, so stop and fix a key that returns 401.
Errors
Every error has the same shape: { "error": { "code", "message", "details", "requestId" } }. Branch on the status, code, and details.code when present; the message is written for people. Every response carries an X-Request-Id header with the same id. Include it when you contact support.
400VALIDATION_ERROR, BAD_REQUESTThe body or query did not match. details lists each field.401UNAUTHORIZEDThe key is missing, mistyped, or revoked.403FORBIDDENThe Distinctful is not on Scale, or your role cannot do this.404NOT_FOUNDNothing with that id in this Distinctful.409CONFLICTThe request conflicts with the current state: approval is missing, the review is already decided, or an Idempotency-Key is still running or was used for a different request. details.code names the reason when it has one.413PAYLOAD_TOO_LARGEThe body is larger than 1 MB.429TOO_MANY_REQUESTSOver a limit. Wait for the Retry-After seconds, then retry.503BILLING_STATE_UNKNOWN, SERVICE_UNAVAILABLEPlan status or media storage could not be reached, so nothing ran. Retry after Retry-After.OpenAPI spec
The OpenAPI 3.1 description is built from the same schemas the API validates with, so it never drifts from what the endpoints accept. Import it into Postman or Insomnia, or generate a client from it.
https://app.distinctful.com/api/v1/openapi.jsonQuestions or a missing endpoint? Email support@distinctful.com.