Weekend Wanderer API Reference
RESTful JSON API. All endpoints are versioned under /api/v1/.
Health
/api/health
Liveness probe. Unversioned (note the missing /v1), unauthenticated
and unthrottled, and the only endpoint that answers outside the
success/data/message envelope — the payload is the top-level object.
▶ Response example
Authentication
Admin endpoints require a Bearer token obtained via POST /api/v1/admin/login.
Pass the token in the Authorization header on every protected request.
1. Obtain a token
2. Use the token
3. Revoke the token
Tokens expire after 24 hours. Multiple concurrent sessions are supported: each login mints an additional token and leaves existing ones working, so one account can be signed in from a phone, a laptop and a CI job at once. There is no single-session limit and no token cap.
| Action | Calling token | The account's other tokens |
|---|---|---|
POST /admin/login | n/a — mints a new one | Untouched, all stay valid |
POST /admin/logout | Revoked | Untouched, all stay valid |
PUT /admin/profile changing the password | Survives | All revoked |
| 24 hours elapsing | Expires | Each on its own 24-hour clock |
Signing out on one device therefore leaves the others signed in. The single exception is a password change through
PUT /admin/profile — the
“I may be compromised” case — which revokes every other token on the account. Treat a sudden
401 on a previously working token as “the password was changed
elsewhere”, not as a bug.
Login answers 401 for a wrong password and for a correct password
on a non-admin account — the two are deliberately indistinguishable. A valid token belonging to a non-admin account
gets 403 Forbidden from the admin endpoints, not
401.
Response Format
Every response is JSON with a consistent envelope.
Success
Paginated list
Error
HTTP status codes used
| Code | Meaning |
|---|---|
| 200 | OK |
| 201 | Created |
| 401 | Unauthenticated — missing, invalid or expired token; also returned for bad login credentials |
| 403 | Forbidden — the token belongs to a non-admin account, or demo mode is blocking a write |
| 404 | Not Found |
| 409 | Conflict (e.g. duplicate email, or deleting a category that still has articles) |
| 422 | Validation error |
| 429 | Rate limit exceeded |
| 500 | Server error |
Rate Limits
Limits are keyed on the caller's IP address, except the authenticated bucket, which is keyed on the
admin user id and so is shared across every token that account holds. Exceeding a limit returns HTTP
429. Every throttled response carries
X-RateLimit-Limit and
X-RateLimit-Remaining.
GET /api/health is not throttled at all.
| Scope | Limit | Applies to |
|---|---|---|
| Public | 60 / minute | /home, /articles, /categories, /tags, /search, /settings |
| Authenticated | 120 / minute | All /admin/* endpoints except login |
| Admin login | 5 / 15 minutes | POST /admin/login |
| Affiliate | 30 / minute | GET /affiliates/{slug}/redirect |
| Newsletter | 5 / minute | POST /newsletter/subscribe |
| Contact | 5 / minute | POST /contact |
Home
/api/v1/home
Everything the rendered homepage shows, from the same source, so the two cannot drift apart. Sections are cached for 300 seconds; the cache key folds in the resolved category ids and the next scheduled publication, so a renamed category or an article falling due is reflected immediately.
| Key | Type | Contents |
|---|---|---|
| latest_articles | array | Up to 6 newest published articles from latest_category |
| featured_articles | array | Up to 4 newest published featured articles from featured_category |
| popular_guides | array | Up to 6 published articles site-wide, by descending view_count |
| stats | array | Three {"value","label"} pairs: Weekend Guides, Categories, Guide Views. value is a pre-abbreviated string ("1.2K"), not a number |
| latest_category | object|null | The category the latest_articles cards came from |
| featured_category | object|null | The category the featured_articles cards came from |
The two categories come from configured slugs, but those are only a preference — categories are
created and deleted freely in the admin panel. When a slug no longer resolves, the section falls back to
whichever real category currently holds the newest matching published article, preferring one the other
section is not already using. On an empty site either can still be null,
in which case its article array is [].
Clients must hide the section and its “view all” link when the category is null
rather than linking to a slug that would 404.
▶ Response example
Articles
/api/v1/articles
Returns a paginated list of published articles (15 per page).
Query Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| category | string | No | Filter by category slug |
| tag | string | No | Filter by tag slug |
| search | string | No | Substring match on title or plain-text body. Max 255 |
| is_featured | boolean | No | 1/true keeps only featured, 0/false keeps only non-featured. Any other value is a 422 |
| sort | string | No | popular orders by view count; anything else, including an unrecognised value, falls back to newest first |
| page | integer | No | Page number (default: 1) |
▶ Response example
/api/v1/articles/{slug}
Returns a single published article by slug; an unpublished, future-dated or unknown slug is a
404. Increments the view counter once per IP,
so repeat requests from the same client do not inflate view_count.
Includes the sanitized HTML body, a
toc array built from its headings, and up to
4 related articles (same category, topped up from same tags, then latest).
Note: The article's canonical web page at
/articles/{slug}
also emits full Open Graph (og:title, og:description,
og:image, og:url, og:type,
article:published_time, article:section)
and Twitter Card (twitter:card summary_large_image,
twitter:title, twitter:description, twitter:image)
meta tags for rich social sharing previews.
▶ Response example
Categories
/api/v1/categories
Returns all categories ordered by name — not paginated, so there is no
links/meta wrapper here.
article_count counts only published articles, so it matches exactly
what /categories/{slug} will page through. (The admin listing counts
drafts and binned articles too, and so can report a higher number for the same category.)
▶ Response example
/api/v1/categories/{slug}
Returns a category and its paginated published articles (15 per page).
▶ Response example
Search
/api/v1/search
Searches published articles by title and body text, 15 per page, newest first. Matching is case- and
accent-insensitive, so cafe finds “CAFÉ”;
%, _ and
\ are treated as literals rather than wildcards.
If q is omitted or empty, returns all published articles.
The response is the same paginated envelope as /articles —
data.data, data.links,
data.meta — with no separate query echo.
Query Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| q | string | No | Search query (if empty, lists all articles) |
| page | integer | No | Page number |
Affiliates
/api/v1/affiliates/{slug}/redirect
Logs a click and returns the partner URL. Despite the path, it does not issue an HTTP redirect
— it answers 200 with the usual envelope, and the client
navigates to data.redirect_url itself.
Unknown slugs return 404. The returned URL is always
http or https;
other schemes are rejected at write time.
▶ Response example
Settings
/api/v1/settings
Returns public site settings: site_name,
site_description, ga_id,
contact_email, site_logo
and site_favicon. No other key is exposed.
Unlike the admin endpoint, site_logo and
site_favicon here are ready-to-use absolute URLs,
not stored paths, and fall back to the bundled assets when nothing has been uploaded — so they are never empty.
They are derived from APP_URL rather than the requesting host, so a stale
APP_URL in production yields URLs the client cannot reach.
▶ Response example
Contact
/api/v1/contact
Stores a contact message. Returns 201 on success.
Body (JSON)
| Field | Type | Required | Rules |
|---|---|---|---|
| name | string | Yes | Max 100 chars |
| string | Yes | Valid email, max 255 chars | |
| message | string | Yes | Max 5000 chars |
All admin endpoints require the Authorization: Bearer <token> header. Obtain a token via Admin Auth → Login.
Missing or expired tokens get 401; a valid token on a non-admin account gets 403.
When the deployment runs with demo mode enabled, reads still work but every write answers
403 "Demo mode is enabled." — logout is the one exception, so a demo session is never trapped.
Admin — Auth
/api/v1/admin/login
Authenticates using email and password. Returns a 24-hour Bearer token. Rate-limited to 5 attempts per 15 minutes.
Body (JSON)
| Field | Type | Required | Description |
|---|---|---|---|
| string | Yes | Admin email address | |
| password | string | Yes | Admin password |
▶ Response example
/api/v1/admin/logout
🔒 Auth required
Revokes the current token. No body required.
Admin — Articles
/api/v1/admin/articles
🔒 Auth required
Paginated list, 15 per page, newest first. ?status=all|published|draft|trashed (default all; an unrecognised value falls back to all rather than erroring). ?search= matches the title only. status=published includes future-scheduled articles, which the public endpoints hide.
/api/v1/admin/articles
🔒 Auth required
Create an article. Responds 201. If a soft-deleted article already holds the requested slug it is recycled instead: the binned row is restored in place, keeping its id, resetting view_count to 0 and discarding what it previously contained.
▶ Request body fields
| Field | Type | Required | Rules |
|---|---|---|---|
| title | string | Yes | Max 255 |
| slug | string | Yes | Lowercase letters, digits, - and _ only. Max 255. Unique among live articles — a slug held only by a binned article is accepted and recycles that row |
| content | string | Yes | HTML. Max 65,535 chars. Sanitized on the way out; excerpt and reading_time are derived from it and cannot be set directly |
| category_id | integer | Yes | Must exist in categories |
| tags | array | No | Tag IDs; each must exist. Replaces the existing set outright — omitting it removes every tag, it does not preserve them |
| is_published | boolean | No | Defaults to false when omitted |
| is_featured | boolean | No | Defaults to false when omitted |
| meta_title | string | No | Max 255 |
| meta_description | string | No | Max 500 |
| featured_image | file | No | JPG, JPEG, PNG or WebP. Max 2 MB. Requires multipart/form-data. Omitting it leaves the current image untouched |
| published_at | datetime | No | Any parseable date; a future date schedules the article. Filled with the current time when is_published is true and no date is set |
/api/v1/admin/articles/{id}
🔒 Auth required
Get a single article by numeric ID. Non-numeric ids do not match the route and return 404.
/api/v1/admin/articles/{id}
🔒 Auth required
Update an article. Full-representation: title, slug, content and category_id are required on every call, and omitting is_published or is_featured resets them to false. Binned articles cannot be edited — restore first, or this returns 404.
▶ Request body fields
| Field | Type | Required | Rules |
|---|---|---|---|
| title | string | Yes | Max 255 |
| slug | string | Yes | Lowercase letters, digits, - and _ only. Max 255. Unique among live articles (ignores own record) |
| content | string | Yes | HTML. Max 65,535 chars. Sanitized on the way out; excerpt and reading_time are derived from it and cannot be set directly |
| category_id | integer | Yes | Must exist in categories |
| tags | array | No | Tag IDs; each must exist. Replaces the existing set outright — omitting it removes every tag, it does not preserve them |
| is_published | boolean | No | Defaults to false when omitted |
| is_featured | boolean | No | Defaults to false when omitted |
| meta_title | string | No | Max 255 |
| meta_description | string | No | Max 500 |
| featured_image | file | No | JPG, JPEG, PNG or WebP. Max 2 MB. Requires multipart/form-data. Omitting it leaves the current image untouched |
| published_at | datetime | No | Any parseable date; a future date schedules the article. Filled with the current time when is_published is true and no date is set — an existing date is kept, never overwritten |
/api/v1/admin/articles/{id}
🔒 Auth required
Identical to PUT — same handler, same required fields. This is not a partial update.
▶ Request body fields
| Field | Type | Required | Rules |
|---|---|---|---|
| title | string | Yes | Max 255 |
| slug | string | Yes | Lowercase letters, digits, - and _ only. Max 255. Unique among live articles (ignores own record) |
| content | string | Yes | HTML. Max 65,535 chars. Sanitized on the way out; excerpt and reading_time are derived from it and cannot be set directly |
| category_id | integer | Yes | Must exist in categories |
| tags | array | No | Tag IDs; each must exist. Replaces the existing set outright — omitting it removes every tag, it does not preserve them |
| is_published | boolean | No | Defaults to false when omitted |
| is_featured | boolean | No | Defaults to false when omitted |
| meta_title | string | No | Max 255 |
| meta_description | string | No | Max 500 |
| featured_image | file | No | JPG, JPEG, PNG or WebP. Max 2 MB. Requires multipart/form-data. Omitting it leaves the current image untouched |
| published_at | datetime | No | Any parseable date; a future date schedules the article. Filled with the current time when is_published is true and no date is set — an existing date is kept, never overwritten |
/api/v1/admin/articles/{id}
🔒 Auth required
Soft-delete an article. It leaves the public endpoints but stays retrievable under ?status=trashed.
/api/v1/admin/articles/{id}/restore
🔒 Auth required
Restore a soft-deleted article. Returns the restored article.
▶ Request body fields
| Field | Type | Required | Rules |
|---|---|---|---|
| title | string | Yes | Max 255 |
| slug | string | Yes | Lowercase letters, digits, - and _ only. Max 255. Unique among live articles — a slug held only by a binned article is accepted and recycles that row |
| content | string | Yes | HTML. Max 65,535 chars. Sanitized on the way out; excerpt and reading_time are derived from it and cannot be set directly |
| category_id | integer | Yes | Must exist in categories |
| tags | array | No | Tag IDs; each must exist. Replaces the existing set outright — omitting it removes every tag, it does not preserve them |
| is_published | boolean | No | Defaults to false when omitted |
| is_featured | boolean | No | Defaults to false when omitted |
| meta_title | string | No | Max 255 |
| meta_description | string | No | Max 500 |
| featured_image | file | No | JPG, JPEG, PNG or WebP. Max 2 MB. Requires multipart/form-data. Omitting it leaves the current image untouched |
| published_at | datetime | No | Any parseable date; a future date schedules the article. Filled with the current time when is_published is true and no date is set |
/api/v1/admin/articles/{id}/force
🔒 Auth required
Permanently delete an article that is already in the bin — it must be soft-deleted first, or this returns 404. Irreversible: the row, its tag links and its uploaded image are all removed.
Admin — Categories
article_count here counts every article in the category
— drafts, future-scheduled and binned included — so it will not always agree with the published-only count the
public /categories endpoint reports for the same category.
/api/v1/admin/categories
🔒 Auth required
Paginated list ordered by name, 20 per page — not the full set in one response.
/api/v1/admin/categories
🔒 Auth required
Create a category. Responds 201.
▶ Request body fields
| Field | Type | Required | Rules |
|---|---|---|---|
| name | string | Yes | Max 100 |
| slug | string | Yes | Lowercase letters, digits, - and _ only. Max 100. Unique |
| description | string | No | Max 1000 |
/api/v1/admin/categories/{id}
🔒 Auth required
Update a category. Full-representation: name and slug are required on every call.
▶ Request body fields
| Field | Type | Required | Rules |
|---|---|---|---|
| name | string | Yes | Max 100 |
| slug | string | Yes | Lowercase letters, digits, - and _ only. Max 100. Unique (ignores own record) |
| description | string | No | Max 1000 |
/api/v1/admin/categories/{id}
🔒 Auth required
Identical to PUT — same handler, same required fields.
▶ Request body fields
| Field | Type | Required | Rules |
|---|---|---|---|
| name | string | Yes | Max 100 |
| slug | string | Yes | Lowercase letters, digits, - and _ only. Max 100. Unique (ignores own record) |
| description | string | No | Max 1000 |
/api/v1/admin/categories/{id}
🔒 Auth required
Delete a category. Returns 409 if any article still points at it, including soft-deleted ones sitting in the bin.
Admin — Affiliates
/api/v1/admin/affiliates
🔒 Auth required
Paginated list ordered by name, 20 per page — not the full set in one response. Each entry carries id, name, slug, url, click_count, created_at and updated_at.
/api/v1/admin/affiliates
🔒 Auth required
Create an affiliate link. Responds 201 with click_count at 0.
▶ Request body fields
| Field | Type | Required | Rules |
|---|---|---|---|
| name | string | Yes | Max 255 |
| slug | string | Yes | Lowercase letters, digits, - and _ only. Max 255. Unique. This is the value [affiliate slug="..."] shortcodes reference |
| url | string | Yes | Max 2048. Scheme must be http or https — this value is handed straight to the browser |
/api/v1/admin/affiliates/{id}
🔒 Auth required
Update an affiliate link. Full-representation: name, slug and url are required on every call.
▶ Request body fields
| Field | Type | Required | Rules |
|---|---|---|---|
| name | string | Yes | Max 255 |
| slug | string | Yes | Lowercase letters, digits, - and _ only. Max 255. Unique (ignores own record). This is the value [affiliate slug="..."] shortcodes reference |
| url | string | Yes | Max 2048. Scheme must be http or https — this value is handed straight to the browser |
/api/v1/admin/affiliates/{id}
🔒 Auth required
Identical to PUT — same handler, same required fields.
▶ Request body fields
| Field | Type | Required | Rules |
|---|---|---|---|
| name | string | Yes | Max 255 |
| slug | string | Yes | Lowercase letters, digits, - and _ only. Max 255. Unique (ignores own record). This is the value [affiliate slug="..."] shortcodes reference |
| url | string | Yes | Max 2048. Scheme must be http or https — this value is handed straight to the browser |
/api/v1/admin/affiliates/{id}
🔒 Auth required
Delete an affiliate link and its recorded clicks. Returns 409 while any article body still embeds an [affiliate] shortcode for its slug, since deleting would leave those articles pointing at a dead /out/{slug} link — the message names how many.
Admin — Messages
/api/v1/admin/messages
🔒 Auth required
Paginated list of contact form messages (15 per page), newest first. Each entry carries id, name, email, message, is_read and created_at.
/api/v1/admin/messages/{id}/toggle
🔒 Auth required
Flips is_read and returns the updated message. It toggles rather than sets, so there is no way to force a particular state — send it twice and you are back where you started. PATCH only; PUT is not routed.
/api/v1/admin/messages/{id}
🔒 Auth required
Permanently deletes a contact message.
Admin — Settings
/api/v1/admin/settings
🔒 Auth required
Retrieve all website settings (including non-public administration settings).
/api/v1/admin/settings
🔒 Auth required
Update website settings. Supports JSON or multipart/form-data for logo and favicon image uploads.
/api/v1/admin/settings
🔒 Auth required
Same handler as POST. Use POST when uploading files, since multipart bodies are not parsed on PUT.
▶ Request body fields (POST / PUT)
| Field | Type | Required | Rules |
|---|---|---|---|
| site_name | string | Yes | Max 255. Required on every call, even one that only changes the favicon |
| site_description | string | No | Max 5000 |
| ga_id | string | No | Max 255 |
| contact_email | string | No | Valid email, max 255 |
| site_logo | file | No | JPG, JPEG, PNG or WebP. Max 2 MB. multipart/form-data. No SVG — it is XML, can carry script, and would be served from this origin |
| site_favicon | file | No | JPG, JPEG, PNG, WebP or ICO. Max 512 KB. multipart/form-data |
| remove_site_logo | boolean | No | Clears the uploaded logo and reverts to the bundled asset |
| remove_site_favicon | boolean | No | Clears the uploaded favicon and reverts to the bundled asset |
Both verbs return the same payload, and every key is always present — an unset setting comes back as "" rather than being omitted. site_logo and site_favicon are the stored paths on the public disk; the accompanying site_logo_url and site_favicon_url are absolute URLs for display, falling back to the bundled assets when nothing has been uploaded.
Admin — Profile
/api/v1/admin/profile
🔒 Auth required
The authenticated admin account: id, name, email and created_at. No password field is ever returned.
/api/v1/admin/profile
🔒 Auth required
Update name and email, and optionally the password. Full-representation: name and email are required on every call. PATCH is not routed. A wrong current_password is a 422 carrying errors.current_password. Changing the password revokes every other token on the account — the token making the call survives, all others stop working immediately.
▶ Request body fields
| Field | Type | Required | Rules |
|---|---|---|---|
| name | string | Yes | Max 100 |
| string | Yes | Valid email, max 255, unique among users (ignores own record) | |
| current_password | string | Only with new_password | The account's existing password |
| new_password | string | No | Max 72 — bcrypt hashes only the first 72 bytes. Must satisfy the app's default password rules and be confirmed |
| new_password_confirmation | string | Only with new_password | Must match new_password |
Admin — Stats
/api/v1/admin/stats
🔒 Auth required
Dashboard summary metrics.
▶ Response example
Weekend Wanderer API v1 — Generated 2026