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.

KindBody
tweettext 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.
linkedintext
newslettersubject, body, and an optional preheader
x_articletitle (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:

  • published means the piece has been sent somewhere. It keeps that status even if it's archived afterwards.
  • archived means it was put away without being sent.
  • all is 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

StatusWhat it means
400The body doesn't match its kind, or a queued send has locked the piece. msg says which.
401Missing or invalid API key.
404No such piece, post, or group in this publication, or the publication hasn't been opened in the app yet.
409The piece is already grouped with another post. Remove it from that group in the app first.

On this page