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 merchant role on the workspace sees drafts in GET /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.