Public REST API

12 min readUpdated 26 Apr 2026

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

PlanAPI AccessCalls/day
FreeNo
ProYes10,000
BusinessYes100,000
AgencyYesUnlimited

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:

Authenticate with API key
curl https://yourblog.velocms.org/api/v1/posts \
  -H "Authorization: Bearer velo_<your-key-here>"
bash

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.

ScopeGrants
posts:readGET /posts, GET /posts/:id
posts:writePOST /posts, PATCH /posts/:id, DELETE /posts/:id
media:readGET /media, GET /media/:id
media:writePOST /media, DELETE /media/:id
comments:readGET /comments
comments:writePOST /comments
members:readGET /members (anonymized)
site-settings:readGET /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.

HeaderDescription
X-RateLimit-LimitMax requests per window (100)
X-RateLimit-RemainingRemaining requests in the current window
X-RateLimit-ResetUnix epoch when the window resets
Retry-AfterSeconds 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 response
{
  "error": {
    "code": "INVALID_SCOPE",
    "message": "This endpoint requires the 'posts:write' scope. Your key has: posts:read.",
    "details": {}
  }
}
json
CodeHTTP statusMeaning
UNAUTHORIZED401Missing or invalid API key
INVALID_SCOPE403Key lacks required scope
RATE_LIMITED429Per-minute limit exceeded
QUOTA_EXCEEDED429Daily plan quota exceeded
NOT_FOUND404Record does not exist in your tenant
VALIDATION_ERROR422Request body failed schema validation
INTERNAL_ERROR500/503Server-side error
PLAN_UPGRADE_REQUIRED403Feature not available on current plan

Endpoint reference

MethodPathScopeDescription
GET/api/v1/postsposts:readList posts (paginated, filterable by status)
POST/api/v1/postsposts:writeCreate a post (auto-publishes if status=published)
GET/api/v1/posts/:idposts:readGet a single post
PATCH/api/v1/posts/:idposts:writeUpdate post fields
DELETE/api/v1/posts/:idposts:writeDelete a post
GET/api/v1/mediamedia:readList media (filter by type)
POST/api/v1/mediamedia:writeUpload a file (multipart/form-data)
GET/api/v1/media/:idmedia:readGet media record
DELETE/api/v1/media/:idmedia:writeDelete media record
GET/api/v1/commentscomments:readList comments (filter by post_id, status)
POST/api/v1/commentscomments:writeCreate comment (auto-approved)
GET/api/v1/membersmembers:readList readers (email masked)
GET/api/v1/site-settingssite-settings:readRead 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.

Paginate through posts
# 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>"
bash

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.

EventFired when
post.publishedA post transitions to published status
post.updatedA published post is edited
post.deletedA post is deleted
comment.receivedA comment is approved
subscriber.addedA new reader signs up
subscriber.removedA reader unsubscribes
test.pingYou click Test in the admin UI

Webhook payload format

post.published payload
{
  "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"
  }
}
json

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.

Verify HMAC signature (TypeScript)
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);
}
typescript

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

Bulk import script
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}`);
}
typescript