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/jsonThe Accept: application/json header is required — without it, some requests return HTML redirects instead of JSON.
Endpoints
Auth endpoints
Blog endpoints
Post endpoints
?status=publishedPost image endpoints
Statistics endpoints
?period=1|7|30|90Page endpoints
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
| Field | Type | Description |
|---|---|---|
| string | Required. Account email address | |
| password | string | Required. Account password |
| device_name | string | Required. Label for the token (max 255) |
| totp_code | string | 6-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
| Field | Type | Description |
|---|---|---|
| name | string | Required. Display name (max 255) |
| string | Required. Must be unique | |
| password | string | Required. Minimum 8 characters |
| password_confirmation | string | Required. Must match password |
| device_name | string | Required. 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/blogsCreate 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/postsUpload 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/imagesPublish 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/publishBlogs
Blog fields
| Field | Type | Description |
|---|---|---|
| name | string | Blog display name (max 255). Required on create. |
| slug | string | URL slug, lowercase with hyphens, unique. Required on create. |
| author_name | string | Author name shown on blog (max 255). Required on create. |
| description | string | Blog description (max 1000) |
| discoverable | boolean | Appear in the explore directory |
| theme | string | Theme identifier (update only) |
| custom_domain | string | Custom domain (update only) |
| meta_title | string | SEO title (max 255, update only) |
| meta_description | string | SEO description (max 1000, update only) |
| og_image | string | Social sharing image URL (update only) |
| language | string | Language 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
| Field | Type | Description |
|---|---|---|
| title | string | Post title (max 255). Required on create, optional on update. |
| slug | string | URL slug, lowercase with hyphens, unique per blog. Required on create, optional on update. |
| content | string | Post body as HTML (sanitized server-side) |
| excerpt | string | Short summary for listings (max 1000) |
| status | string | draft or published |
| tags | string[] | Tag names. Created automatically if new. Replaces all tags on update. |
| featured_image | string | Featured image URL. Cannot be combined with featured_image_file. |
| featured_image_file | file | Upload 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)
| Field | Type | Description |
|---|---|---|
| image | file | Required. Image file (JPEG, PNG, GIF, WebP, max 10 MB) |
| alt_text | string | Alt text (max 255) |
Update fields (PATCH, JSON)
| Field | Type | Description |
|---|---|---|
| alt_text | string | Alt text (max 255) |
| sort_order | integer | Sort 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
| Field | Type | Description |
|---|---|---|
| title | string | Page title (max 255). Required on create, optional on update. |
| slug | string | URL slug, unique per blog. Required on create, optional on update. |
| content | string | Page body as HTML (sanitized server-side) |
| status | string | draft 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
| Param | Type | Description |
|---|---|---|
| period | integer | 1 (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 limitedErrors
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 exceededPagination
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 }
}