API Reference

10 min readUpdated 27 Apr 2026

VeloCMS exposes two API surfaces: the PocketBase tenant API (standard PocketBase REST endpoints at your POCKETBASE_URL) and the VeloCMS webhook API (/api/stripe/webhook and /api/member-webhook/[tenantSlug]). This reference covers both, with a focus on the auth model, webhook signing, and the three endpoints you'll actually hit when building integrations.

Collections overview

CollectionPurposeAuth required
postsBlog posts — title, content_html/json, slug, status, tagsAdmin or member (visibility-gated)
mediaUploaded files — filename, url, mime_type, size, dimensionsAdmin
site_settingsBlog name, description, theme, integrations configAdmin
blog_membersReader accounts — email, tier, stripe_customer_idAdmin or self
categoriesPost categories — name, slug, descriptionAdmin
tenantsTenant registry (multi-mode only) — slug, owner_idAdmin

Auth model

VeloCMS has two separate auth systems that must never be confused (P-022). Admin auth is for blog owners — credentials live in PocketBase's built-in _superusers collection and the session is stored in a pb_auth cookie. Member (reader) auth is for subscribers — stored in blog_members and the session uses a pb_member_auth cookie. An admin can read everything. A member can only read public posts plus their own member record.

Authenticate as admin
# Get an admin auth token
curl -X POST '$POCKETBASE_URL/api/admins/auth-with-password' \
  -H 'Content-Type: application/json' \
  -d '{"identity":"admin@example.com","password":"yourpassword"}'

# Response includes a token field — use it as a Bearer header:
# Authorization: Bearer <token>
bash
Authenticate as member
# Member (reader) login — against blog_members collection
curl -X POST '$POCKETBASE_URL/api/collections/blog_members/auth-with-password' \
  -H 'Content-Type: application/json' \
  -d '{"identity":"reader@example.com","password":"memberpassword"}'
bash

Listing posts

The posts collection supports PocketBase's standard query syntax for filtering, sorting, and pagination. Published public posts are readable without auth:

List published posts
curl '$POCKETBASE_URL/api/collections/posts/records?filter=(status="published")&sort=-published_at&page=1&perPage=20'
bash

Creating a post

Creating a post requires admin auth. The content_json field holds the TipTap JSON AST; content_html is the rendered HTML for display. You can set either or both — if you only set content_html, the editor won't be able to reopen the post for editing.

Create a post
curl -X POST '$POCKETBASE_URL/api/collections/posts/records' \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer <admin-token>' \
  -d '{
    "title": "My first post",
    "slug": "my-first-post",
    "content_html": "<p>Hello, world.</p>",
    "status": "draft",
    "visibility": "public",
    "tags": ["intro"],
    "tenant_id": "<your-tenant-id>"
  }'
bash

Uploading media

Media uploads go to the media collection as multipart/form-data. VeloCMS backend stores the file in Cloudflare R2 and saves the public URL in the url field:

Upload a file
curl -X POST '$POCKETBASE_URL/api/collections/media/records' \
  -H 'Authorization: Bearer <admin-token>' \
  -F 'file=@/path/to/image.jpg' \
  -F 'tenant_id=<your-tenant-id>'
bash

Webhook signing — HMAC SHA-256

Both webhook endpoints (/api/stripe/webhook and /api/member-webhook/[tenantSlug]) verify incoming payloads using HMAC-SHA256 signatures. This prevents replay attacks and ensures the payload wasn't tampered with in transit.

For Stripe webhooks, the signature lives in the Stripe-Signature header and is validated using your STRIPE_WEBHOOK_SECRET (the one from the Stripe Dashboard endpoint, not the CLI). For the member-webhook endpoint, the signature is in an X-VeloCMS-Signature header and uses a per-tenant HMAC secret stored in site_settings.member_webhook_secret.

Verify member-webhook signature (example)
import { createHmac } from "crypto";

function verifyMemberWebhook(
  rawBody: string,
  signature: string,
  secret: string
): boolean {
  const expected = createHmac("sha256", secret)
    .update(rawBody)
    .digest("hex");
  return `sha256=${expected}` === signature;
}
typescript

Member-webhook payload

The /api/member-webhook/[tenantSlug] endpoint receives events from Stripe when a reader's subscription changes. The payload shape varies by event type:

Event typePayload fieldsAction
member.signupemail, tier, stripeCustomerIdCreate blog_members record
member.upgradedemail, tier, previousTierUpdate blog_members.tier
member.cancelledemail, effectiveDateDowngrade to free tier on effectiveDate
member.payment_failedemail, attemptCountNotify member, restrict on 3rd failure

HMAC unsubscribe links

Newsletter unsubscribe links include an HMAC token that proves the link was generated by VeloCMS for a specific email address — preventing one subscriber from unsubscribing another. The token is generated server-side and validated on the /member/unsubscribe route:

Generate unsubscribe token (server-side)
import { createHmac } from "crypto";

function generateUnsubscribeToken(email: string, secret: string): string {
  return createHmac("sha256", secret).update(email).digest("hex");
}

// Link format: /member/unsubscribe?email=<encoded>&token=<hmac>
typescript

Reference

Collections overview

CollectionKey fieldsAuth requiredDefault list rule
postsid, title, slug, content_html, content_json, excerpt, status, visibility, published_at, tags, tenant_idAdmin (write) / Public (read published)public/members-only based on visibility
mediaid, filename, url, mime_type, size, width, height, tenant_id, createdAdmintenant_id.owner_id = @request.auth.id
site_settingsid, site_name, description, theme, logo, favicon, social_links, attribution_hidden, tenant_idAdmintenant_id.owner_id = @request.auth.id
blog_membersid, email, name, tier, stripe_customer_id, newsletter, createdAdmin or self@request.auth.id != '' && (id = @request.auth.id || tenant_id.owner_id = @request.auth.id)
categoriesid, name, slug, description, tenant_idAdmin (write) / Public (read)public
pagesid, slug, title, ast, status, seo_title, seo_description, tenant_idAdmin (write) / Public (read published)status = 'published' || tenant_id.owner_id = @request.auth.id
tenantsid, slug, owner_id, plan, custom_domain, createdAdmin (master DB only)owner_id = @request.auth.id
ai_call_logid, tenant_id, model, prompt_tokens, completion_tokens, endpoint, createdAdmintenant_id.owner_id = @request.auth.id

PocketBase REST endpoint patterns

OperationMethodURL patternAuth
List recordsGET/api/collections/{name}/records?filter=...&sort=...&page=1&perPage=20None or Bearer
Get one recordGET/api/collections/{name}/records/{id}None or Bearer
Create recordPOST/api/collections/{name}/recordsBearer required
Update recordPATCH/api/collections/{name}/records/{id}Bearer required
Delete recordDELETE/api/collections/{name}/records/{id}Bearer required
Admin authPOST/api/admins/auth-with-passwordNone (returns token)
Member authPOST/api/collections/blog_members/auth-with-passwordNone (returns token)
Refresh tokenPOST/api/admins/auth-refreshBearer (existing token)

Filter syntax

PocketBase filter examples
# Simple equality
?filter=(status="published")

# Combined with AND
?filter=(status="published" && visibility="public")

# Date range
?filter=(published_at >= "2026-01-01" && published_at < "2026-02-01")

# Relation field (tenant isolation)
?filter=(tenant_id.owner_id = "{userId}")

# Full-text search (requires @search index on field)
?filter=(title ~ "velocms")

# Multiple values (OR)
?filter=(status="published" || status="draft")
bash

Pagination parameters

ParameterTypeDefaultDescription
pagenumber1Current page (1-indexed)
perPagenumber30Records per page (max: 500)
sortstring-createdField to sort by. Prefix - for descending. Multiple: sort=-published_at,title
filterstringFilter expression. URL-encode special characters.
expandstringComma-separated relation fields to expand inline
fieldsstringComma-separated field names to include in response (reduces payload)
skipTotalbooleanfalseSkip totalItems + totalPages calculation (faster for large collections)

API error codes

HTTP codePB error codeMeaningCommon cause
400validation_*Invalid field value or required field missingWrong field name, type mismatch, empty required field
401Authentication requiredMissing or expired Bearer token
403Forbidden — API rule check failedRequesting another tenant's data, or insufficient permissions
404Record not foundWrong ID, wrong collection name, or API rules filter it out
429Rate limit exceededMore than 30 req/60s (per-tenant AI limit) or plan quota exceeded
500Internal server errorPocketBase crash, disk full, migration error

Curl examples — posts collection

List published posts (no auth)
curl -G '$POCKETBASE_URL/api/collections/posts/records' \
  --data-urlencode 'filter=(status="published" && visibility="public")' \
  --data-urlencode 'sort=-published_at' \
  --data-urlencode 'page=1' \
  --data-urlencode 'perPage=20' \
  --data-urlencode 'fields=id,title,slug,excerpt,published_at,tags'
bash
Get one post (no auth)
curl '$POCKETBASE_URL/api/collections/posts/records/{id}'
bash
Update post status (admin auth required)
curl -X PATCH '$POCKETBASE_URL/api/collections/posts/records/{id}' \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer <admin-token>' \
  -d '{"status":"published","published_at":"2026-04-27T12:00:00Z"}'
bash
Create blog_members record (admin auth)
curl -X POST '$POCKETBASE_URL/api/collections/blog_members/records' \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer <admin-token>' \
  -d '{
    "email": "reader@example.com",
    "name": "Jane Reader",
    "tier": "free",
    "newsletter": true,
    "tenant_id": "<tenant-id>"
  }'
bash

VeloCMS webhook endpoints

EndpointMethodSignature headerPurpose
/api/stripe/webhookPOSTStripe-Signature (Stripe SDK)Platform Stripe events: checkout.session.completed, invoice.payment_succeeded, customer.subscription.deleted
/api/member-webhook/[tenantSlug]POSTX-VeloCMS-Signature (HMAC-SHA256)Tenant Stripe events for BYOK billing: member.signup, member.upgraded, member.cancelled
/api/ai/generatePOST (SSE)X-Tenant-ID headerGemini streaming AI generation. Returns text/event-stream. 30 req/60s + plan daily quota.
/api/revalidatePOSTAuthorization: Bearer CRON_SECRETOn-demand ISR cache invalidation. Triggers rebuild of post/blog pages.
/api/onboarding/statusGETCookie: pb_authPolls tenant provisioning status after signup. Returns {ready: boolean}.