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. Draft posts with no publishedAt sort last.
Query parameters
| Param | Notes |
|---|---|
status |
Optional filter. One of draft, published. Omit to get both. |
Response
{
"data": {
"posts": [
{
"id": "bp_01HX...",
"accountId": "acc_01HX...",
"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. Use Ripllo's /uploads endpoint to host these, or any external CDN. |
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. |
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. |
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 bp_… 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('bp_01HX...');
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('bp_01HX...', { status: 'published' });
// Schedule for next Monday
await ripllo.blog.update('bp_01HX...', {
status: 'published',
publishedAt: '2026-05-20T08:00:00Z',
});
// Unpublish (back to draft)
await ripllo.blog.update('bp_01HX...', { status: 'draft' });
| Status | error.code |
When |
|---|---|---|
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('bp_01HX...');
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": "bp_01HX...",
"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": "bp_01HX...",
"accountId": "acc_01HX...",
"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 | bp_ + ULID. |
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. |
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. Anyone with
merchantrole on the workspace sees drafts inGET /blog. There's no per-author privacy; if you need that, 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.