Blog
The blog resource lets the merchant publish long-form storefront content — product launches, brand stories, how-to guides — from a single canonical place. Posts live on Ripllo and are served back out through Storlaunch's /s/:merchant/blog + /s/:merchant/blog/:slug + RSS routes, but the source of truth is here.
Each post is a markdown document with a slug, status (draft/published), publish-time scheduling, tags, and SEO meta fields. The (accountId, slug) unique constraint guards each merchant's slug namespace.
Endpoints
| Method | Path | Purpose |
|---|---|---|
GET |
/api/v1/blog |
List posts (merchant) |
POST |
/api/v1/blog |
Create a post |
GET |
/api/v1/blog/:id |
Retrieve a post |
PATCH |
/api/v1/blog/:id |
Update a post |
DELETE |
/api/v1/blog/:id |
Delete a post |
GET |
/api/v1/blog/public/:accountId |
Public listing (storefront, no auth) |
GET |
/api/v1/blog/public/:accountId/:slug |
Public single post (storefront, no auth) |
List posts
GET /api/v1/blog
Returns up to 200 posts in the workspace, sorted by publishedAt desc then createdAt desc. Postgres sorts NULLS FIRST on a DESC column, so drafts that have never been published (publishedAt: null) come back first, not last — and with the 200-row cap, a workspace with hundreds of drafts can push published posts out of the response entirely. Pass ?status=published when you only care about live posts.
Query parameters
| Param | Notes |
|---|---|
status |
Optional filter. One of draft, published. Omit to get both. |
Response
{
"data": {
"posts": [
{
"id": "clx7a2m4q0000pw2f1c8e3d9a",
"accountId": "acc_9f2c1b7a",
"slug": "how-we-source-cotton",
"title": "How we source our cotton",
"excerpt": "From farm to shelf, here's our supply chain.",
"body": "# How we source our cotton\n\nWe work with...",
"coverImage": "https://cdn.ripllo.com/...",
"status": "published",
"publishedAt": "2026-05-10T08:00:00.000Z",
"authorName": "Alice Tan",
"tags": ["sustainability", "supply-chain"],
"metaTitle": null,
"metaDescription": null,
"createdAt": "2026-05-08T14:22:00.000Z",
"updatedAt": "2026-05-10T08:00:00.000Z"
}
]
},
"error": null,
"meta": { "requestId": "...", "timestamp": "..." }
}
There is no cursor pagination on this endpoint — the 200-row cap is the contract. For larger archives, use date-range filters via public listing on the storefront side.
Create a post
POST /api/v1/blog
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
slug |
string (1–160, ^[a-z0-9][a-z0-9-]*$) |
yes | URL-safe identifier. Must be unique within the workspace. Once you publish under a slug, don't change it; you'll break inbound links. |
title |
string (1–200) | yes | Headline. |
excerpt |
string (≤400) | null | no | Short summary for listing pages. Falls back to the first paragraph of body on the storefront if null. |
body |
string | yes | Markdown source. No size cap, but be reasonable — the body ships in full on every public read. |
coverImage |
string (URL) | null | no | Hero image URL — must be absolute (https://…); a relative path is rejected. Any external CDN works. To host it on Ripllo, POST /api/v1/uploads/sign-merchant with kind: "compose-asset" and PUT the bytes to the returned url; the stored object is public-read, so the resulting CDN URL is durable. There is no blog-specific upload kind, and passing anything other than compose-asset or merchant-logo returns 400. |
status |
enum | no | draft (default) or published. |
publishedAt |
ISO 8601 | null | no | When the post becomes visible. If status = "published" and publishedAt is omitted, Ripllo stamps the current time. A future publishedAt is the scheduling mechanism — the storefront hides the post until that time arrives. |
authorName |
string (≤120) | null | no | Display name. Free-form — not linked to any user record. |
tags |
string[] (each ≤64, ≤20 entries) | no | Used for filtering on the storefront. |
metaTitle |
string (≤160) | null | no | <title> override for SEO. Falls back to title if null. |
metaDescription |
string (≤400) | null | no | <meta description> override. Falls back to excerpt if null. |
marketingCampaignId |
string | null | no | Optional parent marketing campaign. null detaches. Linked posts show up under the campaign's blogPosts and in its _count. |
Response — 201 Created
The full BlogPost object wrapped as { post: ... }.
Errors
| Status | error.code |
When |
|---|---|---|
400 |
VALIDATION |
Slug doesn't match the regex, missing required field, oversized tag. |
400 |
VALIDATION |
marketingCampaignId not found in this account — the campaign id belongs to another workspace (or doesn't exist). |
409 |
SLUG_TAKEN |
A post with that slug already exists in this workspace. |
Examples
await ripllo.blog.create({
slug: 'how-we-source-cotton',
title: 'How we source our cotton',
body: '# How we source our cotton\n\nWe work with...',
status: 'published',
tags: ['sustainability', 'supply-chain'],
authorName: 'Alice Tan',
});
ripllo.blog.create(
slug='how-we-source-cotton',
title='How we source our cotton',
body='# How we source our cotton\n\nWe work with...',
status='published',
tags=['sustainability', 'supply-chain'],
author_name='Alice Tan',
)
_, err := client.Blog.Create(ctx, &ripllo.BlogCreateParams{
Slug: "how-we-source-cotton",
Title: "How we source our cotton",
Body: "# How we source our cotton\n\nWe work with...",
Status: "published",
Tags: []string{"sustainability", "supply-chain"},
AuthorName: ripllo.String("Alice Tan"),
})
ripllo_curl POST '/api/v1/blog' \
'{"slug":"how-we-source-cotton","title":"How we source our cotton","body":"...","status":"published"}'
Retrieve a post
GET /api/v1/blog/:id
Returns one post by its ID. Includes the full body. Draft posts and future-publishedAt posts are returned the same as published ones on this merchant-facing route — visibility filters only apply to the public routes.
const { post } = await ripllo.blog.get('clx7a2m4q0000pw2f1c8e3d9a');
Update a post
PATCH /api/v1/blog/:id
Partial. Send only the fields you want to change. The slug is mutable but you should treat it as locked once the post is published — inbound links break.
There's one piece of magic worth knowing: when you transition status from anything to published for the first time and don't supply a publishedAt, Ripllo stamps publishedAt = now(). This means your editing UI doesn't need to manage "did the user explicitly schedule, or just hit publish?" — the API does the right thing either way.
// Publish a draft now
await ripllo.blog.update('clx7a2m4q0000pw2f1c8e3d9a', { status: 'published' });
// Schedule for next Monday
await ripllo.blog.update('clx7a2m4q0000pw2f1c8e3d9a', {
status: 'published',
publishedAt: '2026-05-20T08:00:00Z',
});
// Unpublish (back to draft)
await ripllo.blog.update('clx7a2m4q0000pw2f1c8e3d9a', { status: 'draft' });
| Status | error.code |
When |
|---|---|---|
400 |
VALIDATION |
marketingCampaignId not found in this account. |
404 |
NOT_FOUND |
Post doesn't exist in this workspace. |
409 |
SLUG_TAKEN |
New slug collides. |
Delete a post
DELETE /api/v1/blog/:id
Hard delete. The row is removed. Inbound links to the post's public URL start returning 404 immediately.
If you want to preserve the post but hide it, set status: 'draft' instead.
await ripllo.blog.delete('clx7a2m4q0000pw2f1c8e3d9a');
Public listing
GET /api/v1/blog/public/:accountId
No auth. Returns up to 100 published posts for the storefront, ordered by publishedAt desc. Posts with future publishedAt are filtered out server-side.
Only a subset of fields is exposed — no body, metaTitle, metaDescription, or updatedAt:
{
"data": {
"posts": [
{
"id": "clx7a2m4q0000pw2f1c8e3d9a",
"slug": "how-we-source-cotton",
"title": "How we source our cotton",
"excerpt": "From farm to shelf, here's our supply chain.",
"coverImage": "https://cdn.ripllo.com/...",
"authorName": "Alice Tan",
"tags": ["sustainability", "supply-chain"],
"publishedAt": "2026-05-10T08:00:00.000Z"
}
]
},
"error": null,
"meta": { "requestId": "...", "timestamp": "..." }
}
This is the shape the storefront's blog index page renders. To get the full post body, follow up with public single post.
Public single post
GET /api/v1/blog/public/:accountId/:slug
No auth. Returns the full post by slug for storefront rendering. Filters by status = 'published' and (publishedAt IS NULL OR publishedAt <= now()).
{
"data": {
"post": {
"id": "clx7a2m4q0000pw2f1c8e3d9a",
"accountId": "acc_9f2c1b7a",
"slug": "how-we-source-cotton",
"title": "How we source our cotton",
"excerpt": "From farm to shelf, here's our supply chain.",
"body": "# How we source our cotton\n\nWe work with...",
"coverImage": "https://cdn.ripllo.com/...",
"status": "published",
"publishedAt": "2026-05-10T08:00:00.000Z",
"authorName": "Alice Tan",
"tags": ["sustainability", "supply-chain"],
"metaTitle": null,
"metaDescription": null
}
},
"error": null,
"meta": { "requestId": "...", "timestamp": "..." }
}
| Status | error.code |
When |
|---|---|---|
404 |
NOT_FOUND |
No published post with that slug. Drafts and future-scheduled posts also return 404 here — intentional, so the storefront can't be tricked into linking to unpublished content. |
The blog post object
| Field | Type | Nullable | Notes |
|---|---|---|---|
id |
string | no | Bare cuid (e.g. clx7a2m4q0000pw2f1c8e3d9a) — no prefix. |
accountId |
string | no | Owning workspace. |
slug |
string | no | URL-safe. Unique per (accountId, slug). |
title |
string | no | |
excerpt |
string | yes | Listing summary. |
body |
string | no | Markdown source. |
coverImage |
string (URL) | yes | |
status |
enum | no | draft or published. |
publishedAt |
ISO 8601 | yes | When the post becomes visible on storefront. Future = scheduled. |
authorName |
string | yes | Free-form. |
tags |
string[] | no | Up to 20 entries. |
metaTitle, metaDescription |
string | yes | SEO overrides. |
marketingCampaignId |
string | yes | Parent marketing campaign, if any. |
createdAt, updatedAt |
ISO 8601 | no |
Authoring conventions
- Slug stability. Once published, don't rename. If you must, add a redirect at the storefront layer.
- Image hosting. Ripllo doesn't transcode or optimise images for you. Pre-resize before uploading, or use a CDN that does (Cloudinary, Imgix).
- Code blocks. Standard fenced markdown works. The storefront renders with no syntax highlighting by default; opt in via a theme override.
- Drafts share workspace. The blog router only requires authentication — unlike contacts, contact lists, segments and campaigns, it carries no
merchantrole gate. Any authenticated principal scoped to the workspace (whatever role its token holds) can list, read, edit and delete posts, drafts included. There's no per-author privacy; if you need either restriction, gate at the application layer.
Events
The blog resource doesn't emit outbox events. Adding blog_post.published.v1 is on the backlog — it'd be useful for "newsletter on publish" automations, but until automations themselves ship as a first-class resource it would have no consumers.
Next
- Pixels — track readers as ad-network conversion events.
- Marketing campaigns — broadcast a "new post" email to your contact list.