API guides
Drafted content
Upload X posts, LinkedIn posts, newsletters, and X articles to a publication's library over the REST API.
Alongside its articles (posts in the API), a Paragraph publication keeps a library of short-form content: X posts and X threads, LinkedIn posts, one-off emails, and X articles. Teams see it in the app under Content, and Paragraph fills it as it works.
The /v1/content endpoints let you fill it too. Draft something anywhere (a script, a CI job, your own editor) and it lands in the library ready to review.
Uploading creates a draft. Nothing is posted, emailed, or scheduled until someone approves it, in the app or with the publish and approve endpoints.
Kinds and their bodies
Every piece has a kind, and body carries the content in the shape that kind uses. title names the piece in the library and isn't published anywhere.
| Kind | Body |
|---|---|
tweet | text for a single post (at most 280 characters when creating; an update can go up to 25,000 when the X account can post long), or tweets for a thread (one entry per X post, each following the same limit as a single post). Send one or the other, never both. |
linkedin | text |
newsletter | subject, an optional preheader, and either Markdown body or a complete Tiptap bodyJson document |
x_article | title (the headline X publishes), body as CommonMark markdown, and optional canonicalUrl and coverImageUrl |
Bodies are validated the same way the app validates them, so a thread with a 400-character entry or an Article missing its headline comes back with the same explanation you'd see while editing.
For a newsletter with merged table cells, inline formatting, or embedded media, use bodyJson to preserve its structured document. Provide either body or bodyJson, never both. An article copied to a newsletter keeps its rich document, subject, preview text, and cover without rewriting its words. Inline videos become usable links to the exact saved cut in email.
Keep the existing body format when updating a saved newsletter. Read it first and retain every unchanged node; replacing a rich document with flattened Markdown is refused. Unsupported formatting or unsafe destinations are also refused rather than silently removed.
Create a draft
curl -X POST "https://public.api.paragraph.com/api/v1/content" \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{
"kind": "tweet",
"title": "Thread on writing in public",
"body": { "tweets": ["Writing in public changes what you write.", "Here is what changed for me."] }
}'{
"id": "7f3a2c18-5b9e-4c21-9a0d-8e6b1f4d2a55",
"kind": "tweet",
"title": "Thread on writing in public",
"excerpt": "Writing in public changes what you write.",
"status": "draft",
"scheduled": false,
"lockedReason": null,
"publishedAt": null,
"url": null,
"archivedAt": null,
"bucketId": null,
"createdAt": "2026-08-19T09:12:44.108Z",
"updatedAt": "2026-08-19T09:12:44.108Z",
"body": { "tweets": ["Writing in public changes what you write.", "Here is what changed for me."] }
}List your library
curl "https://public.api.paragraph.com/api/v1/content?kind=tweet&status=draft&limit=20" \
-H "Authorization: Bearer your-api-key"Filter with kind and status, and page with cursor. status takes all (the default), draft, published, or archived:
publishedmeans the piece has been sent somewhere. It keeps that status even if it's archived afterwards.archivedmeans it was put away without being sent.allis everything except pieces archived without being sent.
A sent piece also has url, where it went live, and publishedAt. url is null for drafts and for one-off emails, which have no page to link to. A thread links to its first post.
Lists leave bodies out. Fetch a single piece with GET /v1/content/{id} to read one.
Group a piece with the post it came from
In the app, an article (a post in this API) and everything made from it (the X thread, the LinkedIn version, the newsletter) show as one stacked row under Content. That's a content group, and its ID is the bucketId field.
Create the group from the post. The call is safe to repeat, and a post that already has a group returns the same ID:
curl -X POST "https://public.api.paragraph.com/api/v1/posts/3T2PQZlsdQtigUp4fhlb/bucket" \
-H "Authorization: Bearer your-api-key"{ "bucketId": "c4e1a9d2-30b7-4f68-8a15-2d9c6b0e7f43" }Then pass that ID as bucketId on everything you draft from the post. PATCH takes bucketId too, for a draft you made before the group existed.
Unknown fields are rejected, not ignored. A misspelled key comes back as a 400 naming it, rather than a draft that quietly isn't grouped.
Read a group with GET /v1/buckets/{bucketId} to see what's already in it, so you don't draft a version that exists:
{
"id": "c4e1a9d2-30b7-4f68-8a15-2d9c6b0e7f43",
"title": "Writing in public",
"members": [
{ "kind": "post", "id": "3T2PQZlsdQtigUp4fhlb", "channel": "post", "status": "published", "position": 0 },
{ "kind": "content", "id": "7f3a2c18-5b9e-4c21-9a0d-8e6b1f4d2a55", "channel": "tweet", "status": "draft", "position": 1 }
]
}Members also carry title, url, and timestamps. kind tells you where to read a member: post from /v1/posts/{id}, content from /v1/content/{id}. Videos and a few older members report other, which this API can't fetch yet.
GET /v1/buckets lists every group, most recently active first, and GET /v1/posts/{postId}/bucket returns a post's group (or null) without creating one. Removing a piece from a group is done in the app.
Edit a draft
PATCH renames a piece, replaces its body, or both. body replaces the content entirely, in the same shape POST takes, so send all of it rather than just what changed. Images added in the app are kept.
curl -X PATCH "https://public.api.paragraph.com/api/v1/content/7f3a2c18-5b9e-4c21-9a0d-8e6b1f4d2a55" \
-H "Authorization: Bearer your-api-key" \
-H "Content-Type: application/json" \
-d '{"body": {"text": "Rewritten, and shorter."}}'A piece with a send already queued goes out exactly as written, so editing its body is refused, and lockedReason explains why on every read. Cancel the schedule in the app or through chat first. Renaming always works.
Archive and restore
POST /v1/content/{id}/archive puts a piece away without deleting it and removes it from the app's Content view. POST /v1/content/{id}/restore brings it back as a draft.
Publish and approve
Publishing or approving with an API key counts the same as approving in the app and runs the same checks before anything goes out. Anyone holding the key can do it, so keep keys with the people who approve your posts.
POST /v1/content/{id}/publishposts an X post, X article, or LinkedIn post now. PasspublishAtto schedule an X or LinkedIn post instead, up to 30 days out.GET /v1/schedulelists what's on the Calendar: what your agents made and proposed times for (needsApproval), what's scheduled, what's on hold, what went out, and what failed.POST /v1/schedule/{id}/approveapproves one at its proposed time. Add"publishNow": trueto send it now, which also retries a failed send. Approving also releases a hold.POST /v1/schedule/approveapproves everything waiting for approval, articles and newsletters included, except pieces on hold. Passentriesto approve only some.POST /v1/schedule/{id}/reschedulemoves one to a newpublishAt. It needs approval again at its new time.POST /v1/schedule/{id}/unscheduletakes one off the Calendar without sending it. The draft stays in your library.POST /v1/schedule/{id}/holdpauses one, with an optionalreason, until someone approves it by name.POST /v1/schedule/{id}/accountposts an X or LinkedIn piece from another account, picked fromGET /v1/schedule/accounts. It needs approval again.
Pass entryRevision and contentRevision from the list when approving, so the approval is refused if the piece changed after you read it.
From the CLI
The CLI wraps the same endpoints. --kind picks the kind, and the body flags follow it: --tweet (repeat it for a thread), --subject and --preheader for a newsletter, --headline and --canonical-url for an Article. Long text comes from --text, --file, or stdin.
paragraph content create --kind newsletter --title "October update" --subject "What we shipped" --file ./body.md
paragraph content update <id> --text "Rewritten, and shorter."
paragraph content archive <id>
paragraph content restore <id>
paragraph content publish <id> --at 2026-12-01T16:00:00Z
paragraph schedule list --status needs_approval
paragraph schedule approve --all
paragraph schedule retry <entry-id>
paragraph schedule reschedule <entry-id> --at 2026-12-02T16:00:00Z
paragraph schedule hold <entry-id> --reason "Waiting on legal"
paragraph schedule unschedule <entry-id>
paragraph schedule account <entry-id> --account <account-id>
paragraph content bucket create <post-id>
paragraph content create --kind tweet --title "Thread" --tweet "First." --bucket <bucket-id>
paragraph content bucket for-post <post-id>Errors
| Status | What it means |
|---|---|
400 | The body doesn't match its kind, or a queued send has locked the piece. msg says which. |
401 | Missing or invalid API key. |
404 | No such piece, post, or group in this publication, or the publication hasn't been opened in the app yet. |
409 | The piece is already grouped with another post. Remove it from that group in the app first. |