ULTRV

API Reference

Base URL: https://app.ultrv.com/api/v1

Everything you can do in the editor, you can do over HTTP: create blogs, write and publish posts and pages, upload images, and read your statistics. If you would rather not write the client yourself, the terminal client already speaks this API, and theimporters cover moving an existing blog across.

Authentication

Every request requires a Bearer token. Get your API key from account settings.

Authorization: Bearer ultrv_your_api_key_here
Content-Type: application/json
Accept: application/json

The Accept: application/json header is required — without it, some requests return HTML redirects instead of JSON.

Endpoints

Auth endpoints

POST/loginreturns API token
POST/registercreate account + API key

Blog endpoints

GET/blogs
POST/blogs
GET/blogs/{slug}
PUT/blogs/{slug}
DELETE/blogs/{slug}

Post endpoints

GET/blogs/{slug}/postsfilter: ?status=published
POST/blogs/{slug}/posts
GET/blogs/{slug}/posts/{id}
PUT/blogs/{slug}/posts/{id}
DELETE/blogs/{slug}/posts/{id}
POST/blogs/{slug}/posts/{id}/publish
POST/blogs/{slug}/posts/{id}/unpublish

Post image endpoints

GET/blogs/{slug}/posts/{id}/images
POST/blogs/{slug}/posts/{id}/imagesmultipart/form-data
PATCH/blogs/{slug}/posts/{id}/images/{imageId}
DELETE/blogs/{slug}/posts/{id}/images/{imageId}

Statistics endpoints

GET/blogs/{slug}/statisticsfilter: ?period=1|7|30|90

Page endpoints

GET/blogs/{slug}/pages
POST/blogs/{slug}/pages
GET/blogs/{slug}/pages/{id}
PUT/blogs/{slug}/pages/{id}
DELETE/blogs/{slug}/pages/{id}
POST/blogs/{slug}/pages/{id}/publish
POST/blogs/{slug}/pages/{id}/unpublish
POST/blogs/{slug}/pages/reorder

All paths are relative to /api/v1. Blogs use slugs, posts and pages use numeric IDs.

Login and register

These endpoints do not require a Bearer token. Rate limited to 5 requests per minute.

Login

FieldTypeDescription
emailstringRequired. Account email address
passwordstringRequired. Account password
device_namestringRequired. Label for the token (max 255)
totp_codestring6-digit TOTP code, required if 2FA is enabled

Response

{
  "token": "1|abc123...",
  "user": { "name": "Jane Doe", "email": "jane@example.com" }
}

If 2FA is enabled and no totp_code is provided, returns 403 with "two_factor_required": true.

Register

FieldTypeDescription
namestringRequired. Display name (max 255)
emailstringRequired. Must be unique
passwordstringRequired. Minimum 8 characters
password_confirmationstringRequired. Must match password
device_namestringRequired. Label for the token (max 255)

Response (201)

{
  "token": "ultrv_abc123...",
  "user": { "name": "Jane Doe", "email": "jane@example.com" }
}

Quick Start

List your blogs

curl -H "Authorization: Bearer $API_KEY" \
  -H "Accept: application/json" \
  https://app.ultrv.com/api/v1/blogs

Create a post

curl -X POST \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"title":"My Post","slug":"my-post","content":"<p>Hello</p>","status":"draft"}' \
  https://app.ultrv.com/api/v1/blogs/my-blog/posts

Upload a post image

curl -X POST \
  -H "Authorization: Bearer $API_KEY" \
  -F "image=@photo.jpg" \
  -F "alt_text=A scenic mountain view" \
  https://app.ultrv.com/api/v1/blogs/my-blog/posts/42/images

Publish a post

curl -X POST \
  -H "Authorization: Bearer $API_KEY" \
  -H "Accept: application/json" \
  https://app.ultrv.com/api/v1/blogs/my-blog/posts/42/publish

Blogs

Blog fields

FieldTypeDescription
namestringBlog display name (max 255). Required on create.
slugstringURL slug, lowercase with hyphens, unique. Required on create.
author_namestringAuthor name shown on blog (max 255). Required on create.
descriptionstringBlog description (max 1000)
discoverablebooleanAppear in the explore directory
themestringTheme identifier (update only)
custom_domainstringCustom domain (update only)
meta_titlestringSEO title (max 255, update only)
meta_descriptionstringSEO description (max 1000, update only)
og_imagestringSocial sharing image URL (update only)
languagestringLanguage code, e.g. en (update only)

Response

{
  "data": {
    "id": 1,
    "name": "My Blog",
    "slug": "my-blog",
    "description": "A blog about things.",
    "author_name": "Jane Doe",
    "custom_domain": null,
    "discoverable": true,
    "theme": "default",
    "meta_title": null,
    "meta_description": null,
    "og_image": null,
    "language": "en",
    "canonical_url": "https://my-blog.ultrv.blog",
    "created_at": "2026-01-15T08:00:00+00:00",
    "updated_at": "2026-02-20T14:30:00+00:00"
  }
}

Posts

Post fields

FieldTypeDescription
titlestringPost title (max 255). Required on create, optional on update.
slugstringURL slug, lowercase with hyphens, unique per blog. Required on create, optional on update.
contentstringPost body as HTML (sanitized server-side)
excerptstringShort summary for listings (max 1000)
statusstringdraft or published
tagsstring[]Tag names. Created automatically if new. Replaces all tags on update.
featured_imagestringFeatured image URL. Cannot be combined with featured_image_file.
featured_image_filefileUpload via multipart/form-data. JPEG, PNG, GIF, WebP (max 10 MB). Cannot be combined with featured_image.

Response

{
  "data": {
    "id": 1,
    "title": "Getting Started with ULTRV",
    "slug": "getting-started",
    "excerpt": "A quick guide to setting up your blog.",
    "content": "<p>Welcome to ULTRV...</p>",
    "featured_image": null,
    "status": "draft",
    "published_at": null,
    "tags": [
      { "id": 1, "name": "tutorial", "slug": "tutorial" }
    ],
    "images": [],
    "created_at": "2026-02-26T12:00:00+00:00",
    "updated_at": "2026-02-26T12:00:00+00:00"
  }
}

Post Images

Upload and manage images for a post. Max 20 images per post. Accepted formats: JPEG, PNG, GIF, WebP (max 10 MB). Images are automatically converted to 4 WebP variants.

Upload fields (multipart/form-data)

FieldTypeDescription
imagefileRequired. Image file (JPEG, PNG, GIF, WebP, max 10 MB)
alt_textstringAlt text (max 255)

Update fields (PATCH, JSON)

FieldTypeDescription
alt_textstringAlt text (max 255)
sort_orderintegerSort position

Response

All image URLs are returned as complete, absolute URLs. The urls object contains 4 WebP variants:

{
  "data": {
    "id": 1,
    "original_name": "photo.jpg",
    "alt_text": "A scenic mountain view",
    "width": 1920,
    "height": 1080,
    "size": 2048576,
    "urls": {
      "original": "https://app.ultrv.com/storage/uploads/posts/42/a1b2c3d4_original.webp",
      "large": "https://app.ultrv.com/storage/uploads/posts/42/a1b2c3d4_large.webp",
      "medium": "https://app.ultrv.com/storage/uploads/posts/42/a1b2c3d4_medium.webp",
      "thumb": "https://app.ultrv.com/storage/uploads/posts/42/a1b2c3d4_thumb.webp"
    },
    "sort_order": 0,
    "created_at": "2026-04-05T19:00:00+00:00"
  }
}

Pages

Static content pages (About, Contact, etc.) separate from blog posts.

Page fields

FieldTypeDescription
titlestringPage title (max 255). Required on create, optional on update.
slugstringURL slug, unique per blog. Required on create, optional on update.
contentstringPage body as HTML (sanitized server-side)
statusstringdraft or published

Reorder pages

POST /blogs/{slug}/pages/reorder
{ "pages": [3, 1, 2] }

Array of page IDs in the desired order. Each page's sort_order is set to its array position.

Response

{
  "data": {
    "id": 1,
    "title": "About",
    "slug": "about",
    "content": "<p>Welcome to my blog...</p>",
    "status": "published",
    "sort_order": 0,
    "created_at": "2026-03-01T10:00:00+00:00",
    "updated_at": "2026-03-01T10:00:00+00:00"
  }
}

Statistics

Blog analytics: views, visitors, top posts, devices, countries, and referrers.

Query parameters

ParamTypeDescription
periodinteger1 (today, hourly), 7, 30 (default), or 90 days

Response

{
  "data": {
    "total_views": 1250,
    "unique_visitors": 830,
    "active_visitors": 3,
    "views_by_day": [
      { "date": "2026-04-01", "count": 42, "visitors": 28 }
    ],
    "top_posts": [
      { "title": "Getting Started", "slug": "getting-started", "views": 150 }
    ],
    "by_device": [
      { "label": "desktop", "count": 800, "pct": 64 }
    ],
    "by_country": [
      { "label": "US", "count": 400, "pct": 32 }
    ],
    "by_referrer": [
      { "host": "google.com", "count": 200 }
    ]
  }
}

When period=1, views_by_day returns hourly buckets with ISO timestamps (e.g. 2026-04-09T14:00:00).

Rate Limits

60 requests per minute per API key. Headers included in every response:

X-RateLimit-Limit: 60
X-RateLimit-Remaining: 59
Retry-After: 30  # only when rate limited

Errors

Standard HTTP status codes. Validation errors include field-level detail:

{
  "message": "The slug has already been taken.",
  "errors": { "slug": ["The slug has already been taken."] }
}
401Missing or invalid API key403Not authorized404Resource not found422Validation error429Rate limit exceeded

Pagination

List endpoints return 20 items per page. Navigate with ?page=2.

{
  "data": [...],
  "links": { "first": "...?page=1", "last": "...?page=5", "prev": null, "next": "...?page=2" },
  "meta": { "current_page": 1, "last_page": 5, "per_page": 20, "total": 100 }
}