Public REST API
VeloCMS ships a versioned public REST API at /api/v1 — available on Pro and higher plans. It's designed for import pipelines, mobile apps, and third-party integrations that need programmatic access without going through the admin UI. Every request authenticates with a per-tenant API key scoped to exactly the operations it needs.
Plan requirements
| Plan | API Access | Calls/day |
|---|---|---|
| Free | No | — |
| Pro | Yes | 10,000 |
| Business | Yes | 100,000 |
| Agency | Yes | Unlimited |
Authentication
Generate API keys from Admin → Settings → API Keys. Each key has a name, a set of scopes, and is shown exactly once — VeloCMS stores only the SHA-256 hash, so if you lose the key you'll need to generate a new one. Pass the key in the Authorization header:
curl https://yourblog.velocms.org/api/v1/posts \
-H "Authorization: Bearer velo_<your-key-here>"Scopes
Each API key is restricted to a set of scopes you choose at creation time. A key with posts:read can list and read posts but cannot create or modify them. Requesting an endpoint your key is not scoped for returns HTTP 403 with INVALID_SCOPE.
| Scope | Grants |
|---|---|
| posts:read | GET /posts, GET /posts/:id |
| posts:write | POST /posts, PATCH /posts/:id, DELETE /posts/:id |
| media:read | GET /media, GET /media/:id |
| media:write | POST /media, DELETE /media/:id |
| comments:read | GET /comments |
| comments:write | POST /comments |
| members:read | GET /members (anonymized) |
| site-settings:read | GET /site-settings (encrypted fields redacted) |
Rate limits
The API enforces 100 requests per minute per API key using a sliding window. When you hit the limit the response is HTTP 429 with a Retry-After header telling you how many seconds to wait. In addition to the per-minute cap, plan-level daily quotas apply (Pro: 10k/day, Business: 100k/day). Quota-exceeded requests return 429 with QUOTA_EXCEEDED.
| Header | Description |
|---|---|
| X-RateLimit-Limit | Max requests per window (100) |
| X-RateLimit-Remaining | Remaining requests in the current window |
| X-RateLimit-Reset | Unix epoch when the window resets |
| Retry-After | Seconds to wait when 429 is returned |
Error format
Every error response uses the same JSON envelope, making it easy to handle in client code:
{
"error": {
"code": "INVALID_SCOPE",
"message": "This endpoint requires the 'posts:write' scope. Your key has: posts:read.",
"details": {}
}
}| Code | HTTP status | Meaning |
|---|---|---|
| UNAUTHORIZED | 401 | Missing or invalid API key |
| INVALID_SCOPE | 403 | Key lacks required scope |
| RATE_LIMITED | 429 | Per-minute limit exceeded |
| QUOTA_EXCEEDED | 429 | Daily plan quota exceeded |
| NOT_FOUND | 404 | Record does not exist in your tenant |
| VALIDATION_ERROR | 422 | Request body failed schema validation |
| INTERNAL_ERROR | 500/503 | Server-side error |
| PLAN_UPGRADE_REQUIRED | 403 | Feature not available on current plan |
Endpoint reference
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/v1/posts | posts:read | List posts (paginated, filterable by status) |
| POST | /api/v1/posts | posts:write | Create a post (auto-publishes if status=published) |
| GET | /api/v1/posts/:id | posts:read | Get a single post |
| PATCH | /api/v1/posts/:id | posts:write | Update post fields |
| DELETE | /api/v1/posts/:id | posts:write | Delete a post |
| GET | /api/v1/media | media:read | List media (filter by type) |
| POST | /api/v1/media | media:write | Upload a file (multipart/form-data) |
| GET | /api/v1/media/:id | media:read | Get media record |
| DELETE | /api/v1/media/:id | media:write | Delete media record |
| GET | /api/v1/comments | comments:read | List comments (filter by post_id, status) |
| POST | /api/v1/comments | comments:write | Create comment (auto-approved) |
| GET | /api/v1/members | members:read | List readers (email masked) |
| GET | /api/v1/site-settings | site-settings:read | Read tenant config (no secrets) |
Pagination
All list endpoints support page and per_page query parameters. The maximum per_page is 100. Responses include items, page, per_page, total, and total_pages fields.
# Page 2, 10 per page, published only
curl "https://yourblog.velocms.org/api/v1/posts?page=2&per_page=10&status=published" \
-H "Authorization: Bearer velo_<key>"Webhook subscriptions
Instead of polling the API, register a webhook URL to receive HTTP POST notifications when events happen in your tenant. Go to Admin → Settings → Webhooks. Enter an HTTPS URL, select which events to receive, and save. VeloCMS generates a 32-byte random secret shown once — save it, because you'll need it to verify incoming payloads.
| Event | Fired when |
|---|---|
| post.published | A post transitions to published status |
| post.updated | A published post is edited |
| post.deleted | A post is deleted |
| comment.received | A comment is approved |
| subscriber.added | A new reader signs up |
| subscriber.removed | A reader unsubscribes |
| test.ping | You click Test in the admin UI |
Webhook payload format
{
"event": "post.published",
"timestamp": 1714140000,
"payload": {
"post_id": "abc123",
"title": "My first post",
"slug": "my-first-post",
"published_at": "2026-04-26T10:00:00.000Z",
"tenant_id": "tenant_xyz"
}
}Verifying the HMAC signature
Every delivery includes an X-VeloCMS-Signature header with a v1=<hex> value. The signature is HMAC-SHA256 over the string v1:<timestamp>:<raw-body>, where timestamp is the X-VeloCMS-Timestamp header value. Always verify before processing the payload to prevent replay and spoofing attacks.
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyWebhookSignature(
rawBody: string,
signature: string, // X-VeloCMS-Signature header value
timestamp: string, // X-VeloCMS-Timestamp header value
secret: string // Your webhook secret from the admin UI
): boolean {
const signingInput = `v1:${timestamp}:${rawBody}`;
const expectedSig = createHmac("sha256", secret)
.update(signingInput)
.digest("hex");
const expectedHeader = `v1=${expectedSig}`;
// Use constant-time comparison to prevent timing attacks
const a = Buffer.from(signature);
const b = Buffer.from(expectedHeader);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}Retry behavior
VeloCMS retries failed webhook deliveries 3 times with exponential backoff (1s, 2s, 4s). Your endpoint should return a 2xx status within 10 seconds to be considered successful. After 5 consecutive failures the subscription is automatically disabled and you'll see a notification in the admin settings. You can re-enable it from the Webhooks panel once the issue is resolved.
Example: import posts from an external CMS
const API_KEY = process.env.VELOCMS_API_KEY;
const BASE = "https://yourblog.velocms.org/api/v1";
interface ExternalPost {
title: string;
body_html: string;
tags: string[];
}
async function importPost(p: ExternalPost): Promise<void> {
const res = await fetch(`${BASE}/posts`, {
method: "POST",
headers: {
"Authorization": `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
title: p.title,
content_html: p.body_html,
tags: p.tags,
status: "draft",
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(`Import failed: ${err.error.message}`);
}
const post = await res.json();
console.log(`Imported: ${post.id} — ${post.title}`);
}