API Reference
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
| Collection | Purpose | Auth required |
|---|---|---|
| posts | Blog posts — title, content_html/json, slug, status, tags | Admin or member (visibility-gated) |
| media | Uploaded files — filename, url, mime_type, size, dimensions | Admin |
| site_settings | Blog name, description, theme, integrations config | Admin |
| blog_members | Reader accounts — email, tier, stripe_customer_id | Admin or self |
| categories | Post categories — name, slug, description | Admin |
| tenants | Tenant registry (multi-mode only) — slug, owner_id | Admin |
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.
# 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># 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"}'Listing posts
The posts collection supports PocketBase's standard query syntax for filtering, sorting, and pagination. Published public posts are readable without auth:
curl '$POCKETBASE_URL/api/collections/posts/records?filter=(status="published")&sort=-published_at&page=1&perPage=20'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.
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>"
}'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:
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>'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.
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;
}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 type | Payload fields | Action |
|---|---|---|
| member.signup | email, tier, stripeCustomerId | Create blog_members record |
| member.upgraded | email, tier, previousTier | Update blog_members.tier |
| member.cancelled | email, effectiveDate | Downgrade to free tier on effectiveDate |
| member.payment_failed | email, attemptCount | Notify 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:
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>Reference
Collections overview
| Collection | Key fields | Auth required | Default list rule |
|---|---|---|---|
| posts | id, title, slug, content_html, content_json, excerpt, status, visibility, published_at, tags, tenant_id | Admin (write) / Public (read published) | public/members-only based on visibility |
| media | id, filename, url, mime_type, size, width, height, tenant_id, created | Admin | tenant_id.owner_id = @request.auth.id |
| site_settings | id, site_name, description, theme, logo, favicon, social_links, attribution_hidden, tenant_id | Admin | tenant_id.owner_id = @request.auth.id |
| blog_members | id, email, name, tier, stripe_customer_id, newsletter, created | Admin or self | @request.auth.id != '' && (id = @request.auth.id || tenant_id.owner_id = @request.auth.id) |
| categories | id, name, slug, description, tenant_id | Admin (write) / Public (read) | public |
| pages | id, slug, title, ast, status, seo_title, seo_description, tenant_id | Admin (write) / Public (read published) | status = 'published' || tenant_id.owner_id = @request.auth.id |
| tenants | id, slug, owner_id, plan, custom_domain, created | Admin (master DB only) | owner_id = @request.auth.id |
| ai_call_log | id, tenant_id, model, prompt_tokens, completion_tokens, endpoint, created | Admin | tenant_id.owner_id = @request.auth.id |
PocketBase REST endpoint patterns
| Operation | Method | URL pattern | Auth |
|---|---|---|---|
| List records | GET | /api/collections/{name}/records?filter=...&sort=...&page=1&perPage=20 | None or Bearer |
| Get one record | GET | /api/collections/{name}/records/{id} | None or Bearer |
| Create record | POST | /api/collections/{name}/records | Bearer required |
| Update record | PATCH | /api/collections/{name}/records/{id} | Bearer required |
| Delete record | DELETE | /api/collections/{name}/records/{id} | Bearer required |
| Admin auth | POST | /api/admins/auth-with-password | None (returns token) |
| Member auth | POST | /api/collections/blog_members/auth-with-password | None (returns token) |
| Refresh token | POST | /api/admins/auth-refresh | Bearer (existing token) |
Filter syntax
# 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")Pagination parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
| page | number | 1 | Current page (1-indexed) |
| perPage | number | 30 | Records per page (max: 500) |
| sort | string | -created | Field to sort by. Prefix - for descending. Multiple: sort=-published_at,title |
| filter | string | — | Filter expression. URL-encode special characters. |
| expand | string | — | Comma-separated relation fields to expand inline |
| fields | string | — | Comma-separated field names to include in response (reduces payload) |
| skipTotal | boolean | false | Skip totalItems + totalPages calculation (faster for large collections) |
API error codes
| HTTP code | PB error code | Meaning | Common cause |
|---|---|---|---|
| 400 | validation_* | Invalid field value or required field missing | Wrong field name, type mismatch, empty required field |
| 401 | — | Authentication required | Missing or expired Bearer token |
| 403 | — | Forbidden — API rule check failed | Requesting another tenant's data, or insufficient permissions |
| 404 | — | Record not found | Wrong ID, wrong collection name, or API rules filter it out |
| 429 | — | Rate limit exceeded | More than 30 req/60s (per-tenant AI limit) or plan quota exceeded |
| 500 | — | Internal server error | PocketBase crash, disk full, migration error |
Curl examples — posts collection
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'curl '$POCKETBASE_URL/api/collections/posts/records/{id}'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"}'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>"
}'VeloCMS webhook endpoints
| Endpoint | Method | Signature header | Purpose |
|---|---|---|---|
| /api/stripe/webhook | POST | Stripe-Signature (Stripe SDK) | Platform Stripe events: checkout.session.completed, invoice.payment_succeeded, customer.subscription.deleted |
| /api/member-webhook/[tenantSlug] | POST | X-VeloCMS-Signature (HMAC-SHA256) | Tenant Stripe events for BYOK billing: member.signup, member.upgraded, member.cancelled |
| /api/ai/generate | POST (SSE) | X-Tenant-ID header | Gemini streaming AI generation. Returns text/event-stream. 30 req/60s + plan daily quota. |
| /api/revalidate | POST | Authorization: Bearer CRON_SECRET | On-demand ISR cache invalidation. Triggers rebuild of post/blog pages. |
| /api/onboarding/status | GET | Cookie: pb_auth | Polls tenant provisioning status after signup. Returns {ready: boolean}. |