Paragraph API
REST API and TypeScript SDK for Paragraph — a web3 publishing and newsletter platform. Manage posts, publications, subscribers, and coins without installing any CLI tools.
For CLI or MCP access, see the paragraph-cli skill instead.
Choose your interface
| Interface | Install required | Best for |
|---|---|---|
| REST API | No | Any language, curl, quick scripts |
| TypeScript SDK | npm install @paragraph-com/sdk |
TypeScript/JavaScript projects with type safety |
Authentication
Generate an API key in your publication settings.
Many read endpoints are public — no key needed. Write endpoints (create, update, delete posts, manage subscribers) require authentication.
REST API — pass the key as a Bearer token:
curl https://public.api.paragraph.com/api/v1/me \
-H "Authorization: Bearer <api-key>"
TypeScript SDK:
import { ParagraphAPI } from "@paragraph-com/sdk";
// Public endpoints — no key needed
const api = new ParagraphAPI();
// Authenticated endpoints — pass your API key
const authedApi = new ParagraphAPI({ apiKey: "<api-key>" });
Working agreement
- Do not publish without explicit user approval. Publishing sends content live and optionally emails subscribers.
- Default to draft. Created posts are drafts unless
status: "published"is set explicitly. - Do not send custom emails without explicit user approval.
api.emails.send(andPOST /v1/emails/send) delivers real email and can't be undone. Draft the subject and body first; usedryRun: trueto preview filtering before a real send. On a403, surface "this publication isn't approved for custom email yet" and stop — do not retry. - Drafted content never sends.
api.content(and/v1/content) only writes to the publication's library. Nothing is posted to X or LinkedIn, emailed, or scheduled — the writer sends it from the Paragraph app. Say so rather than implying a piece went out. - Paginate with
cursor. Responses includepagination.cursorandpagination.hasMore. - Respect rate limits. If you get a
429, back off and retry. Avoid tight loops between paginated requests. - Check auth before writing. Call
GET /v1/meorapi.me.get()to verify credentials are valid.
TypeScript SDK
Install
npm install @paragraph-com/sdk
Posts
import { ParagraphAPI } from "@paragraph-com/sdk";
const api = new ParagraphAPI({ apiKey: "<api-key>" });
// Create a draft
const newPost = await api.posts.create({
title: "My Post",
markdown: "# Hello World",
subtitle: "A subtitle",
categories: ["web3", "defi"],
});
// List your posts
const { items: posts, pagination } = await api.posts.list();
const { items: drafts } = await api.posts.list({ status: "draft" });
// Get a single post by ID
const postById = await api.posts.get({ id: "<post-id>" }).single();
// Get a single post by slugs
const postBySlugs = await api.posts.get({ publicationSlug: "my-blog", postSlug: "my-post" }).single();
// Get posts from a publication (paginated)
const { items: pubPosts, pagination: pubPagination } = await api.posts.get(
{ publicationId: "<publication-id>" },
{ limit: 10, includeContent: true }
);
// Update
await api.posts.update({ id: "<post-id>", title: "New Title", markdown: "Updated content" });
await api.posts.update({ slug: "my-post", status: "published" });
// Publish (set status to "published", optionally email subscribers)
await api.posts.update({ id: "<post-id>", status: "published", sendNewsletter: true });
// Schedule a post for future publication
const scheduled = await api.posts.create({
title: "Launch Day",
markdown: "# Launching tomorrow!",
scheduledAt: Date.now() + 24 * 60 * 60 * 1000,
sendNewsletter: true,
});
// scheduled.status === "scheduled"
// Schedule an existing draft
await api.posts.update({ id: "<post-id>", scheduledAt: Date.now() + 86400000 });
// Cancel a scheduled publication
await api.posts.update({ id: "<post-id>", scheduledAt: null });
// Backdate a post — publishedAt sticks across re-publishes
await api.posts.update({
id: "<post-id>",
publishedAt: new Date("2024-01-01T00:00:00Z").getTime(),
});
// Set or replace the cover image — the URL is fetched, re-hosted on Paragraph's CDN, and a placeholder is generated
await api.posts.update({
id: "<post-id>",
imageUrl: "https://example.com/cover.jpg",
});
// Remove the existing cover image
await api.posts.update({ id: "<post-id>", clearImage: true });
// Delete (by ID or by slug)
await api.posts.delete({ id: "<post-id>" });
await api.posts.delete({ slug: "my-post" });
// Send test newsletter email (draft posts only)
await api.posts.sendTestEmail({ id: "<post-id>" });
// Feed and tags
const { items: feed } = await api.feed.get();
const { items: tagged } = await api.posts.get({ tag: "web3" }, { limit: 10 });
Content
The publication's short-form library: X posts and threads, LinkedIn posts, one-off emails, and X Articles. Everything here is a draft — creating or editing a piece never posts, emails, or schedules it. Long-form Paragraph posts are a different resource; use api.posts for those.
Kinds are tweet, linkedin, newsletter, and x_article. body takes the shape its kind uses, validated the same way the Paragraph app validates it. Drafts created through the API are text-only — media has to be uploaded to X or LinkedIn first, which the API can't do yet.
const api = new ParagraphAPI({ apiKey: "<api-key>" });
// Draft an X thread — one entry per tweet, each under 280 characters
const thread = await api.content.create({
kind: "tweet",
title: "Thread on writing in public",
body: {
tweets: [
"Writing in public changes what you write.",
"Here's what changed for me.",
],
},
});
// Draft a single X post
await api.content.create({
kind: "tweet",
title: "Launch note",
body: { text: "We shipped it." },
});
// Draft a LinkedIn post
await api.content.create({
kind: "linkedin",
title: "Launch note",
body: { text: "We shipped it." },
});
// Draft a one-off email
await api.content.create({
kind: "newsletter",
title: "October update",
body: {
subject: "What we shipped in October",
preheader: "Three things worth your time",
body: "Hi everyone,",
},
});
// Draft an X Article — body is CommonMark markdown
await api.content.create({
kind: "x_article",
title: "Why we rebuilt the editor",
body: {
title: "Why we rebuilt the editor",
body: "## The old editor\n\nIt fought us.",
canonicalUrl: "https://example.com/posts/why-we-rebuilt-the-editor",
},
});
// List the library (newest activity first, bodies not included)
const { items, pagination } = await api.content.list();
const { items: drafts } = await api.content.list({ kind: "tweet", status: "draft" });
// Read one piece, with its body — do this before editing
const piece = await api.content.get({ id: "<content-id>" });
// Edit — body replaces the artifact entirely, so send the whole thing
await api.content.update({ id: "<content-id>", body: { text: "Rewritten, and shorter." } });
// Rename — always allowed, even when a queued send has locked the words
await api.content.update({ id: "<content-id>", title: "Launch note, second pass" });
// Archive and restore
await api.content.archive({ id: "<content-id>" });
await api.content.restore({ id: "<content-id>" });
status on list defaults to all, which is everything except the pieces you've archived. A piece that was delivered stays listed whether or not it was archived afterwards.
lockedReason on a piece explains why it can't be edited right now, or is null when it can — a queued or in-flight send locks the words, because they go out exactly as written. Cancel the schedule in the app first.
Content groups
A group is one identity for a post and everything made out of it — the post, the X thread drawn from it, the LinkedIn version, the newsletter. It's what a writer sees as a single stacked row under Content in the Paragraph app.
When repurposing a post, group what you draft with it. Seed the group from the post, then pass the id as bucketId on every piece derived from it. Without it the writer gets unrelated drafts that don't know about each other.
const api = new ParagraphAPI({ apiKey: "<api-key>" });
// Get or create the post's group. Safe to repeat: the same id comes back.
const { bucketId } = await api.buckets.createForPost({ postId: "<post-id>" });
// Read it first, so you don't remake something that already exists
const group = await api.buckets.get({ id: bucketId });
const channels = new Set(group.members.map((m) => m.channel));
if (!channels.has("tweet")) {
await api.content.create({
kind: "tweet",
title: "Thread on writing in public",
body: { tweets: ["Writing in public changes what you write."] },
bucketId,
});
}
// Group a draft made before the group existed
await api.content.update({ id: "<content-id>", bucketId });
// Find a post's group without creating one, or list every group
const existing = await api.buckets.forPost({ postId: "<post-id>" }); // { bucketId: string | null }
const { items } = await api.buckets.list({ limit: 20 });
Every piece reports its group as bucketId on reads and writes, so you can tell what's grouped without guessing.
Each member's kind says where to read it: post with api.posts.get({ id }), content with api.content.get({ id }). A kind of other is a member Paragraph groups but the API can't fetch.
A piece already grouped with a different post is refused with a 409 rather than moved — tell the user to ungroup it in the app. Taking a piece out of a group is done in the app, not through the API.
Publications
const api = new ParagraphAPI();
const pubById = await api.publications.get({ id: "<publication-id>" }).single();
const pubBySlug = await api.publications.get({ slug: "@my-blog" }).single();
const pubByDomain = await api.publications.get({ domain: "blog.example.com" }).single();
Update settings on the publication that owns your API key. Only provided fields change.
const authedApi = new ParagraphAPI({ apiKey: "<api-key>" });
// Name, summary, theme
await authedApi.publications.update("<publication-id>", {
name: "My Blog",
summary: "Notes on writing, building, and shipping.",
themeColor: "purple-600",
headerFont: "serif",
});
// Featured post — accepts "latest", "popular", "disabled", or a post ID
// (a specific ID must belong to this publication).
await authedApi.publications.update("<publication-id>", { featuredPost: "latest" });
// Pinned posts — replaces the existing list, capped at 50 IDs.
// Each ID must belong to this publication.
await authedApi.publications.update("<publication-id>", {
pinnedPostIds: ["<post-id-1>", "<post-id-2>"],
});
// Email notifications — merged onto current settings; only the toggles you
// send are changed.
await authedApi.publications.update("<publication-id>", {
emailNotifications: { newSubscriber: true, newComment: false },
});
Subscribers
const api = new ParagraphAPI({ apiKey: "<api-key>" });
// List
const { items: subscribers, pagination } = await api.subscribers.get({ limit: 50 });
// Count
const { count } = await api.subscribers.getCount({ id: "<publication-id>" });
// Add
await api.subscribers.create({ email: "user@example.com" });
await api.subscribers.create({ wallet: "0x1234..." });
// Remove (hard delete — irreversible). At least one of email or wallet
// must be provided; if you pass both, they must resolve to the same user.
await api.subscribers.remove({ email: "user@example.com" });
await api.subscribers.remove({ wallet: "0x1234..." });
// Import CSV
const file = new File([csvContent], "subscribers.csv", { type: "text/csv" });
await api.subscribers.importCsv({ file });
Coins
const api = new ParagraphAPI();
// Get coin info (by ID or contract address)
const coinById = await api.coins.get({ id: "<coin-id>" }).single();
const coinByContract = await api.coins.get({ contractAddress: "0x1234..." }).single();
// Popular coins
const { items: popular } = await api.coins.get({ sortBy: "popular" });
// Holders (by ID or contract address)
const holders = await api.coins.getHolders({ id: "<coin-id>" }, { limit: 50 });
// Price quote (amount in wei)
const quote = await api.coins.getQuote({ id: "<coin-id>" }, 1000000000000000000n);
// Buy and sell (requires a viem WalletClient and Account)
const txHash = await api.coins.buy({
coin: { id: "<coin-id>" },
client: walletClient,
account,
amount: 1000000000000000000n, // 1 ETH in wei
});
const sellHash = await api.coins.sell({
coin: { id: "<coin-id>" },
client: walletClient,
account,
amount: 1000000000000000000n, // coin amount in wei
});
Search
const api = new ParagraphAPI();
const posts = await api.search.posts("ethereum");
const blogs = await api.search.blogs("web3");
const coins = await api.search.coins("test");
Users
const api = new ParagraphAPI();
const userById = await api.users.get({ id: "<user-id>" }).single();
const userByWallet = await api.users.get({ wallet: "0x1234..." }).single();
Me
const api = new ParagraphAPI({ apiKey: "<api-key>" });
const myPublication = await api.me.get();
Custom emails
Send a one-off markdown email from your publication to a specific recipient list you supply. Each recipient gets it individually with a mandatory unsubscribe footer (uses the same delivery pipeline as the newsletter).
Use this for:
- Targeted segment sends — "email everyone who opened my last post", "follow-up to my 50 most engaged subscribers", "reach out to this curated list of 20 readers". Build the segment via
api.analytics.queryorapi.subscribers.get. - Self-notifications to the writer — "email me when I hit 1,000 subscribers", "weekly analytics digest". Pair with
api.me.get()if you need to look up the writer's email. - Outreach to non-subscriber addresses — a CSV of conference contacts, a press list, an intro to friends-of-friends. Recipients can come from anywhere; they don't have to be in
api.subscribers.get(). (Anyone who previously unsubscribed will still come back assuppressed.) - Re-engagement of inactive subscribers — "email everyone who hasn't opened in 90 days." Identify the segment via
api.analytics.queryagainstsubscriber_engagement_scoresornewsletter_metrics. - Draft review to a few collaborators — "send this draft pitch to my 3 co-authors for feedback." Use this when you need to email people other than the publication owner;
api.posts.sendTestEmailonly goes to the owner.
Do NOT use this for newsletter blasts. To email all subscribers with a post, set sendNewsletter: true on api.posts.create or api.posts.update. That's the newsletter pipeline; api.emails.send is for targeted lists you supply.
Eligibility: publications must be approved by Paragraph for custom email. Ineligible publications get a 403.
Per-recipient filtering is server-side: malformed addresses come back as invalid, prior unsubscribes as suppressed, and queue failures as scheduling_failed (the only reason that's safe to retry).
Caps: up to 10,000 recipients per call.
Always confirm before sending. Email goes out for real and can't be undone — draft the subject and body first, then call send. Use dryRun: true to preview the accepted/skipped split without sending.
const api = new ParagraphAPI({ apiKey: "<api-key>" });
// Send a markdown email to a list of recipients
const { accepted, skipped } = await api.emails.send({
subject: "Hello from Paragraph",
body: "# Welcome\n\nThanks for reading.",
emails: ["reader@example.com", "another@example.com"],
});
console.log(`${accepted} queued, ${skipped.length} skipped`);
for (const s of skipped) {
console.log(`${s.email}: ${s.reason}`);
}
// Dry run — check filtering without sending
const preview = await api.emails.send({
subject: "Preview",
body: "Body here",
emails: ["reader@example.com"],
dryRun: true,
});
Auth (browser-based login)
For CLI or headless clients that need to authenticate without an API key:
const api = new ParagraphAPI();
// Create an auth session — returns a URL the user opens in their browser
const session = await api.auth.createSession({ deviceName: "my-app" });
console.log("Open this URL to approve:", session.verificationUrl);
// Poll until the user approves (status: "pending" → "completed")
const status = await api.auth.getSession(session.sessionId);
if (status.apiKey) {
// Use the returned API key for authenticated requests
const authedApi = new ParagraphAPI({ apiKey: status.apiKey });
}
// Cancel a pending session
await api.auth.deleteSession(session.sessionId);
Pagination
All list methods return { items, pagination }. Paginate with the cursor:
let cursor: string | undefined;
const allPosts = [];
do {
const { items, pagination } = await api.posts.list({ cursor, limit: 50 });
allPosts.push(...items);
cursor = pagination.hasMore ? pagination.cursor : undefined;
} while (cursor);
REST API
Base URL: https://public.api.paragraph.com/api
Posts
# List your posts (requires auth)
curl https://public.api.paragraph.com/api/v1/posts?status=draft&limit=20 \
-H "Authorization: Bearer <api-key>"
# Get post by ID
curl https://public.api.paragraph.com/api/v1/posts/<post-id>
# Get post by slug
curl https://public.api.paragraph.com/api/v1/posts/slug/<slug>
# Get posts from a publication
curl https://public.api.paragraph.com/api/v1/publications/<publication-id>/posts?limit=10&includeContent=true
# Get post by publication slug + post slug
curl https://public.api.paragraph.com/api/v1/publications/slug/<pub-slug>/posts/slug/<post-slug>
# Create a draft (requires auth)
curl -X POST https://public.api.paragraph.com/api/v1/posts \
-H "Authorization: Bearer <api-key>" \
-H "Content-Type: application/json" \
-d '{"title": "My Post", "markdown": "# Hello World"}'
# Update (requires auth)
curl -X PUT https://public.api.paragraph.com/api/v1/posts/<post-id> \
-H "Authorization: Bearer <api-key>" \
-H "Content-Type: application/json" \
-d '{"title": "Updated Title", "status": "published", "sendNewsletter": true}'
# Backdate a post (publishedAt sticks across re-publishes)
curl -X PUT https://public.api.paragraph.com/api/v1/posts/<post-id> \
-H "Authorization: Bearer <api-key>" \
-H "Content-Type: application/json" \
-d '{"publishedAt": 1704067200000}'
# Set/replace the cover image (URL is fetched + re-hosted on Paragraph's CDN)
curl -X PUT https://public.api.paragraph.com/api/v1/posts/<post-id> \
-H "Authorization: Bearer <api-key>" \
-H "Content-Type: application/json" \
-d '{"imageUrl": "https://example.com/cover.jpg"}'
# Remove the existing cover image
curl -X PUT https://public.api.paragraph.com/api/v1/posts/<post-id> \
-H "Authorization: Bearer <api-key>" \
-H "Content-Type: application/json" \
-d '{"clearImage": true}'
# Update by slug (requires auth)
curl -X PUT https://public.api.paragraph.com/api/v1/posts/slug/<slug> \
-H "Authorization: Bearer <api-key>" \
-H "Content-Type: application/json" \
-d '{"title": "Updated Title"}'
# Delete by ID (requires auth)
curl -X DELETE https://public.api.paragraph.com/api/v1/posts/<post-id> \
-H "Authorization: Bearer <api-key>"
# Delete by slug (requires auth)
curl -X DELETE https://public.api.paragraph.com/api/v1/posts/slug/<slug> \
-H "Authorization: Bearer <api-key>"
# Schedule a post for future publication (requires auth)
curl -X POST https://public.api.paragraph.com/api/v1/posts \
-H "Authorization: Bearer <api-key>" \
-H "Content-Type: application/json" \
-d '{"title": "Launch Day", "markdown": "# Launching!", "scheduledAt": 1746090000000, "sendNewsletter": true}'
# Schedule an existing draft (requires auth)
curl -X PUT https://public.api.paragraph.com/api/v1/posts/<post-id> \
-H "Authorization: Bearer <api-key>" \
-H "Content-Type: application/json" \
-d '{"scheduledAt": 1746090000000}'
# Cancel a scheduled publication (requires auth)
curl -X PUT https://public.api.paragraph.com/api/v1/posts/<post-id> \
-H "Authorization: Bearer <api-key>" \
-H "Content-Type: application/json" \
-d '{"scheduledAt": null}'
# Send test newsletter email
curl -X POST https://public.api.paragraph.com/api/v1/posts/<post-id>/test-email \
-H "Authorization: Bearer <api-key>"
# Feed
curl https://public.api.paragraph.com/api/v1/posts/feed?limit=10
# Posts by tag
curl https://public.api.paragraph.com/api/v1/posts/tag/web3?limit=20
Content
Drafted X posts, LinkedIn posts, one-off emails, and X Articles. All six endpoints require auth; the API key identifies the publication. Nothing here is sent — the writer sends it from the Paragraph app.
# Draft an X thread (requires auth)
curl -X POST https://public.api.paragraph.com/api/v1/content \
-H "Authorization: Bearer <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'"'"'s what changed for me."]}
}'
# Draft a LinkedIn post (requires auth)
curl -X POST https://public.api.paragraph.com/api/v1/content \
-H "Authorization: Bearer <api-key>" \
-H "Content-Type: application/json" \
-d '{"kind": "linkedin", "title": "Launch note", "body": {"text": "We shipped it."}}'
# Draft a one-off email (requires auth)
curl -X POST https://public.api.paragraph.com/api/v1/content \
-H "Authorization: Bearer <api-key>" \
-H "Content-Type: application/json" \
-d '{
"kind": "newsletter",
"title": "October update",
"body": {"subject": "What we shipped in October", "body": "Hi everyone,"}
}'
# List the library — filter by kind and status (requires auth)
curl "https://public.api.paragraph.com/api/v1/content?kind=tweet&status=draft&limit=20" \
-H "Authorization: Bearer <api-key>"
# Get one piece, with its body (requires auth)
curl https://public.api.paragraph.com/api/v1/content/<content-id> \
-H "Authorization: Bearer <api-key>"
# Edit — body replaces the artifact entirely (requires auth)
curl -X PATCH https://public.api.paragraph.com/api/v1/content/<content-id> \
-H "Authorization: Bearer <api-key>" \
-H "Content-Type: application/json" \
-d '{"body": {"text": "Rewritten, and shorter."}}'
# Rename (requires auth)
curl -X PATCH https://public.api.paragraph.com/api/v1/content/<content-id> \
-H "Authorization: Bearer <api-key>" \
-H "Content-Type: application/json" \
-d '{"title": "Launch note, second pass"}'
# Archive and restore (requires auth)
curl -X POST https://public.api.paragraph.com/api/v1/content/<content-id>/archive \
-H "Authorization: Bearer <api-key>"
curl -X POST https://public.api.paragraph.com/api/v1/content/<content-id>/restore \
-H "Authorization: Bearer <api-key>"
kind is tweet, linkedin, newsletter, or x_article, and body takes the shape its kind uses:
| Kind | body fields |
|---|---|
| tweet | text for a single post, or tweets (array, one entry per tweet, each ≤ 280 characters) for a thread — never both |
text |
|
| newsletter | subject, body, optional preheader |
| x_article | title (the headline X publishes), body as CommonMark markdown, optional canonicalUrl |
Sending body.media is rejected: media has to be uploaded to X or LinkedIn first, which the API can't do yet. Add images to the draft in the Paragraph app.
Unknown fields are rejected rather than ignored, so a misspelled key comes back as a 400 naming it.
Content groups
A group is one identity for a post and everything made out of it. Seed it from the post, then pass the id as bucketId on everything drafted from that post, so the writer sees one stacked row under Content instead of unrelated drafts.
# Get or create a post's group. Safe to repeat: the same id comes back. (requires auth)
curl -X POST https://public.api.paragraph.com/api/v1/posts/<post-id>/bucket \
-H "Authorization: Bearer <api-key>"
# -> {"bucketId": "<bucket-id>"}
# Draft into it (requires auth)
curl -X POST https://public.api.paragraph.com/api/v1/content \
-H "Authorization: Bearer <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."]},
"bucketId": "<bucket-id>"
}'
# Group a draft made before the group existed (requires auth)
curl -X PATCH https://public.api.paragraph.com/api/v1/content/<content-id> \
-H "Authorization: Bearer <api-key>" \
-H "Content-Type: application/json" \
-d '{"bucketId": "<bucket-id>"}'
# Read a group and everything already made from its post (requires auth)
curl https://public.api.paragraph.com/api/v1/buckets/<bucket-id> \
-H "Authorization: Bearer <api-key>"
# Find a post's group without creating one (requires auth)
curl https://public.api.paragraph.com/api/v1/posts/<post-id>/bucket \
-H "Authorization: Bearer <api-key>"
# -> {"bucketId": null} when nothing has been made from it yet
# List every group, most recently active first (requires auth)
curl "https://public.api.paragraph.com/api/v1/buckets?limit=20" \
-H "Authorization: Bearer <api-key>"
Each member's kind says which endpoint reads it: post with /v1/posts/{id}, content with /v1/content/{id}. A kind of other is a member Paragraph groups but this API can't fetch.
A piece already grouped with a different post is refused with a 409 rather than moved. Taking a piece out of a group is done in the app.
Publications
# By ID
curl https://public.api.paragraph.com/api/v1/publications/<publication-id>
# By slug
curl https://public.api.paragraph.com/api/v1/publications/slug/<slug>
# By custom domain
curl https://public.api.paragraph.com/api/v1/publications/domain/<domain>
# Subscriber count
curl https://public.api.paragraph.com/api/v1/publications/<publication-id>/subscribers/count
# Update settings (requires auth — publicationId must match the API key's publication).
# Only provided fields are changed. featuredPost accepts "latest" | "popular" |
# "disabled" | a post ID. pinnedPostIds replaces the existing list (max 50).
# emailNotifications is merged onto current settings.
curl -X PATCH https://public.api.paragraph.com/api/v1/publications/<publication-id> \
-H "Authorization: Bearer <api-key>" \
-H "Content-Type: application/json" \
-d '{"name": "My Blog", "themeColor": "purple-600", "featuredPost": "latest"}'
Subscribers
# List (requires auth)
curl https://public.api.paragraph.com/api/v1/subscribers?limit=100 \
-H "Authorization: Bearer <api-key>"
# Add (requires auth)
curl -X POST https://public.api.paragraph.com/api/v1/subscribers \
-H "Authorization: Bearer <api-key>" \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com"}'
# Remove (requires auth — hard delete, irreversible). Pass email or wallet
# (or both — both must resolve to the same user).
curl -X DELETE https://public.api.paragraph.com/api/v1/subscribers \
-H "Authorization: Bearer <api-key>" \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com"}'
# Import CSV (requires auth)
curl -X POST https://public.api.paragraph.com/api/v1/subscribers/import \
-H "Authorization: Bearer <api-key>" \
-F "file=@subscribers.csv"
Coins
# Get coin by ID
curl https://public.api.paragraph.com/api/v1/coins/<coin-id>
# Get coin by contract address
curl https://public.api.paragraph.com/api/v1/coins/contract/<address>
# Popular coins
curl https://public.api.paragraph.com/api/v1/coins/list/popular
# Holders (by ID or contract address)
curl https://public.api.paragraph.com/api/v1/coins/<coin-id>/holders?limit=50
curl https://public.api.paragraph.com/api/v1/coins/contract/<address>/holders?limit=50
# Price quote (by ID or contract address, amount in wei)
curl https://public.api.paragraph.com/api/v1/coins/quote/<coin-id>?amount=1000000000000000000
curl https://public.api.paragraph.com/api/v1/coins/quote/contract/<address>?amount=1000000000000000000
# Buy/sell args (for building onchain transactions)
curl https://public.api.paragraph.com/api/v1/coins/buy/<coin-id>?amount=1000000000000000000&walletAddress=0x...
curl https://public.api.paragraph.com/api/v1/coins/sell/<coin-id>?amount=1000000000000000000&walletAddress=0x...
Search
curl https://public.api.paragraph.com/api/v1/discover/search?q=ethereum
curl https://public.api.paragraph.com/api/v1/discover/blogs/search?q=web3
curl https://public.api.paragraph.com/api/v1/discover/coins/search?q=test
Users
curl https://public.api.paragraph.com/api/v1/users/<user-id>
curl https://public.api.paragraph.com/api/v1/users/wallet/<wallet-address>
Me
curl https://public.api.paragraph.com/api/v1/me \
-H "Authorization: Bearer <api-key>"
Custom emails
Send a markdown email from your publication to a list of recipients. The publication must be approved by Paragraph; ineligible publications get a 403. Body is rendered to HTML server-side. Up to 10,000 recipients per call. Always confirm with the user before sending — emails go out for real and can't be undone.
# Send (requires auth)
curl -X POST https://public.api.paragraph.com/api/v1/emails/send \
-H "Authorization: Bearer <api-key>" \
-H "Content-Type: application/json" \
-d '{
"subject": "Hello from Paragraph",
"body": "# Welcome\n\nThanks for reading.",
"emails": ["reader@example.com", "another@example.com"]
}'
# Dry run — preview accepted/skipped without sending
curl -X POST https://public.api.paragraph.com/api/v1/emails/send \
-H "Authorization: Bearer <api-key>" \
-H "Content-Type: application/json" \
-d '{
"subject": "Preview",
"body": "Body here",
"emails": ["reader@example.com"],
"dryRun": true
}'
Response shape:
{
"accepted": 2,
"skipped": [
{ "email": "bad@", "reason": "invalid" },
{ "email": "unsub@example.com", "reason": "suppressed" }
]
}
reason is one of invalid (malformed address), suppressed (recipient previously unsubscribed from this publication), or scheduling_failed (filtering passed but the delivery task could not be queued — the only reason that's safe to retry).
JSON response shapes
Paginated list (note: the REST API and SDK wrap items under items; the CLI uses data instead):
{
"items": [{ "id": "...", "title": "..." }],
"pagination": { "cursor": "abc123", "hasMore": true, "total": 42 }
}
Single item:
{ "id": "...", "title": "...", "markdown": "..." }
Mutation:
{ "success": true }
Error:
{ "success": false, "msg": "Not found", "error": "Not found" }
HTTP status codes: 401 (unauthorized), 403 (forbidden), 404 (not found), 429 (rate limited), 500 (server error).
Common patterns
Create and publish in one flow (SDK)
const api = new ParagraphAPI({ apiKey: "<api-key>" });
const post = await api.posts.create({ title: "My Post", markdown: "# Content" });
await api.posts.update({ id: post.id, status: "published", sendNewsletter: true });
Create and publish in one flow (curl)
POST_ID=$(curl -s -X POST https://public.api.paragraph.com/api/v1/posts \
-H "Authorization: Bearer $PARAGRAPH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"title": "My Post", "markdown": "# Content"}' | jq -r '.id')
curl -X PUT "https://public.api.paragraph.com/api/v1/posts/$POST_ID" \
-H "Authorization: Bearer $PARAGRAPH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"status": "published", "sendNewsletter": true}'
Export all posts as markdown (SDK)
const api = new ParagraphAPI({ apiKey: "<api-key>" });
let cursor: string | undefined;
do {
const { items, pagination } = await api.posts.list({
cursor,
limit: 50,
includeContent: true,
});
for (const post of items) {
fs.writeFileSync(`${post.slug}.md`, post.markdown);
}
cursor = pagination.hasMore ? pagination.cursor : undefined;
} while (cursor);
Post fields reference
Create / Update body:
| Field | Type | Required | Description |
|---|---|---|---|
| title | string | Create only | Post title |
| markdown | string | Create only | Content in markdown |
| subtitle | string | No | Brief summary |
| imageUrl | string | No | Cover image URL. Fetched server-side and re-hosted on Paragraph's CDN; a blur placeholder is generated. Valid on both create and update |
| clearImage | boolean | No | Update only. Set to true to remove the existing cover image. Ignored if imageUrl is also provided |
| slug | string | No | Custom URL slug |
| postPreview | string | No | Preview text (meta description). Keep under 145 chars so it renders without truncation in Google, X, and Farcaster link previews |
| categories | string[] | No | Tags/categories |
| status | string | No | published, draft, or archived |
| sendNewsletter | boolean | No | Email all subscribers on publish. Default: false |
| scheduledAt | number|null | No | Unix ms timestamp to schedule first-publish. null to cancel. Max 30 days out |
| publishedAt | number | No | Unix ms timestamp to set as the post's display publish date. Once set, sticks across re-publishes — useful for backdating imported content |