API guides
Drafted content
Upload X posts, LinkedIn posts, newsletters, and X Articles to a publication's library over the REST API.
Alongside long-form posts, a Paragraph publication keeps a library of short-form content: X posts and threads, LinkedIn posts, one-off emails, and X Articles. Writers see it in the app under Content, and the agent 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 sends it from the app.
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, or tweets for a thread (one entry per X post, at most 280 characters each). Send one or the other, never both. |
linkedin | text |
newsletter | subject, body, and an optional preheader |
x_article | title (the headline X publishes), body as CommonMark markdown, and an optional canonicalUrl |
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.
Drafts created through the API are text-only. Add images to a draft in the app.
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, a post 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 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.
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 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. |