# One Page Love API — Full Reference Base URL: `https://api.onepagelove.com` Version: 0.1.0 (path-prefixed `/v1`) OpenAPI spec: https://api.onepagelove.com/docs/openapi.json Changelog: https://api.onepagelove.com/docs/changelog.md Terms: https://api.onepagelove.com/docs/terms.md **Catalog as of 2026-05-25:** 8,993 sites. `GET /v1/meta` returns current counts and a live/offline breakdown. Every record is hand-curated, classified by design aesthetic (genre, color, style, typeface, platform, tech, sections, author), captured at 2× retina full-page resolution, and preserved permanently — even once the original URL goes dark. The catalog stretches back to 2008 and is added to weekly. ## Authentication All endpoints except `POST /v1/register` and `GET /v1/meta` require a Bearer token: ``` Authorization: Bearer opl__ ``` Keys are 49 characters total. The `` is the 12-hex prefix used for lookups; the `` is a 32-hex random string. Keys are hashed with per-key salt (SHA-256) in the database and compared in constant time. Revoked keys return HTTP 403 with `{ "error": "forbidden" }`. ## Rate limits - **Authenticated endpoints:** 100 requests per rolling 60-second window. - **Registration:** 5 requests per hour per IP. Responses include these headers on authenticated routes: - `X-RateLimit-Limit` — ceiling for the window - `X-RateLimit-Remaining` — requests left - `X-RateLimit-Reset` — Unix timestamp when the window resets Exceeded: HTTP 429 with `{ "error": "rate_limited", "message": "..." }`. ## Error shape All error responses: ```json { "error": "", "message": "" } ``` | Code | HTTP | When | |-----------------|------|-----------------------------------------------------------------------| | `unauthorized` | 401 | Missing / malformed `Authorization` header | | `forbidden` | 403 | Key exists but is revoked | | `bad_request` | 400 | Invalid or missing required parameter | | `not_found` | 404 | No resource matches the given slug or filter | | `rate_limited` | 429 | Window ceiling exceeded | | `internal` | 500 | Unhandled server error | ## Registration ### `POST /v1/register` **Public, throttled to 5 requests / hour per IP.** Create a free API key. Email is required; same email can hold up to 2 active keys simultaneously. **Request body (JSON):** | Field | Type | Required | Notes | |--------|--------|----------|----------------------------------------| | email | string | yes | Normalised to lowercase + trimmed | | name | string | no | Optional display label for the key | **Response 201:** ```json { "api_key": "opl_c01ae8f73d8c_b04698b458b49f45b529c9b2723a60a3", "key_id": "c01ae8f73d8c", "plan": "free", "message": "Store this key securely. It will not be shown again." } ``` **The full `api_key` is returned exactly once.** After this response the server only stores the hash + salt — there is no key-retrieval endpoint. **Error responses:** - 400 `bad_request` — invalid / missing email - 429 `limit_reached` — this email already has 2 active keys ## Meta ### `GET /v1/meta` **Public, unauthenticated.** Returns current catalog counts. Cached server-side for 1 hour. **Response 200:** ```json { "service": "opl-api", "version": "0.9.1", "inspirations": { "total": 8993, "live": 2,012, "offline": 6,981 }, "taxonomies": { "genre": N, "color": N, "style": N, "typeface": 1,069, "platform": N, "tech": N, "sections": N, "authors": 2,061 }, "updated_at": "2026-05-25T00:00:00.000Z" } ``` Use this endpoint to display a live catalog size in client UIs or to verify data freshness. ## Account ### `GET /v1/me` Returns the authenticated key's own metadata + usage counters. **Response 200:** ```json { "key_id": "c01ae8f73d8c", "email": "dev@example.com", "plan": "free", "usage": { "today": 42, "total": 1337 } } ``` ## Inspirations ### `GET /v1/inspirations` Search the inspiration catalog. All filters AND together. **Query parameters:** | Name | Type | Default | Notes | |------------|----------|----------|-------------------------------------------------------------------------------------------| | limit | integer | 20 | 1–100 | | offset | integer | 0 | Pagination offset | | sort | string | newest | `newest` \| `oldest` — by publish date | | status | string | all | `all` \| `live` \| `offline` — offline = 10+ years old, auto-marked by weekly cron | | year | integer | — | YYYY filter | | genre | string | — | Taxonomy slug (e.g. `portfolio`, `saas`) | | color | string | — | Taxonomy slug (e.g. `dark`). `colour` accepted as alias. | | colour | string | — | Alias for `color` | | style | string | — | Taxonomy slug (e.g. `minimal`, `scroll-effects`) | | typeface | string | — | Taxonomy slug (e.g. `inter`, `geist`) | | platform | string | — | Taxonomy slug (e.g. `framer`, `webflow`). What ships the site — one per site. | | tech | string | — | Taxonomy slug (e.g. `gsap`, `tailwind`, `nextjs`). What's used to build — stackable. | | sections | string | — | Section type slug (e.g. `pricing-table`) | | fields | string | all | Comma-separated subset of response fields (see below) | > The `authors` parameter is also accepted for backwards compatibility (attribution slug) but is not a primary filter — most consumers search via genre/color/style. **Available `fields` values:** `title`, `slug`, `url`, `published`, `offline`, `genre`, `color`, `style`, `typeface`, `sections`, `platform`, `tech`, `authors`, `screenshot` **Response 200:** ```json { "total": 26, "limit": 20, "offset": 0, "results": [ { "title": "Pixels Studio", "slug": "pixels-studio-2026", "url": "https://pixels.studio", "published": "2026-03-28", "offline": false, "genre": ["Portfolio"], "color": ["Dark"], "style": ["Minimal"], "typeface": ["Fragment", "Inter"], "sections": ["Biography", "Client Logo Row", "Testimonials"], "platform": "Framer", "tech": ["GSAP", "Lottie"], "authors": ["Pixels Studio"], "screenshot": { "hero": "https://onepagelove.com/cdn-cgi/image/w=1280,h=800,fit=cover,g=top,f=auto,q=85/wp-content/uploads/2026/03/pixels-studio.jpg", "thumbnail": "https://onepagelove.com/cdn-cgi/image/w=400,h=300,fit=cover,g=top,f=auto,q=85/wp-content/uploads/2026/03/pixels-studio.jpg", "full": "https://onepagelove.com/cdn-cgi/image/w=2560,f=auto,q=85/wp-content/uploads/2026/03/pixels-studio.jpg", "original": "https://onepagelove.com/wp-content/uploads/2026/03/pixels-studio.jpg", "mobile": "https://onepagelove.com/cdn-cgi/image/w=375,f=auto,q=85/wp-content/uploads/2026/03/pixels-studio-mobile.jpg", "width": 2560, "height": 6400 } } ] } ``` Notes: - `url` is `null` for offline entries (sites older than 10 years are auto-marked offline; their screenshots and taxonomy stay intact). - `platform` is a single string, not an array — a site is built on one platform. - `tech` is an array — a site often stacks multiple frameworks/libraries (e.g. `["GSAP", "Lottie", "Tailwind CSS"]`). Empty array if none detected. - `published` is ISO date only (`YYYY-MM-DD`), no time. - `authors` is always present in responses — it credits the designer/agency/studio who built the one-pager and is intended as attribution metadata. ### `GET /v1/inspirations/{slug}` Single inspiration lookup by slug. **Response 200:** single object matching the shape of an item in `results` above. **Errors:** 404 `not_found` if the slug does not match any inspiration. ## Sections ### `GET /v1/sections` Search cropped page sections. Each result is a Cloudflare-cropped image of a specific section (hero, pricing table, testimonials, etc.) plus full parent-site metadata. The `type` parameter is **required**. **Query parameters:** | Name | Type | Default | Notes | |-----------|----------|---------|----------------------------------------------------------------------------| | type | string | — | **Required.** Section type slug (e.g. `pricing-table`, `hero`) | | limit | integer | 20 | 1–100 | | offset | integer | 0 | — | | sort | string | newest | `newest` \| `oldest` on parent post date | | status | string | all | `all` \| `live` \| `offline` | | width | integer | 640 | 100–2560 — output crop width in pixels; height follows aspect ratio | | genre | string | — | Filters by parent-site taxonomy | | color | string | — | Parent-site taxonomy | | colour | string | — | Alias for `color` | | style | string | — | Parent-site taxonomy | | typeface | string | — | Parent-site taxonomy | | platform | string | — | Parent-site taxonomy | | tech | string | — | Parent-site taxonomy | | fields | string | all | Comma-separated subset (see below) | **Available `fields` values:** `image`, `section_type`, `prompt`, `tailwind`, `colors`, `wireframe_prompt`, `parent_title`, `parent_slug`, `parent_url`, `parent_published`, `parent_offline`, `parent_genre`, `parent_color`, `parent_style`, `parent_typeface`, `parent_platform`, `parent_tech`, `parent_authors`, `parent_screenshot` **Response 200:** ```json { "total": 18, "limit": 20, "offset": 0, "section_type": { "name": "Pricing Table", "slug": "pricing-table" }, "results": [ { "image": "https://assets.onepagelove.com/cdn-cgi/image/trim=2400;0;1800;0,width=640,height=320,fit=cover,format=jpg,quality=85/wp-content/uploads/2026/03/parker.jpg", "section_type": "Pricing Table", "prompt": "Three-tier pricing with monthly/annual toggle.", "parent": { "title": "Parker", "slug": "parker", "url": "https://heyparker.ai/", "published": "2026-02-15", "offline": false, "genre": ["SaaS"], "color": ["Dark"], "style": ["Minimal"], "typeface": ["Inter"], "platform": "Framer", "tech": ["GSAP"], "authors": ["Parker Team"], "screenshot": { "hero": "...", "thumbnail": "...", "full": "...", "original": "..." } } } ] } ``` The `image` URL uses Cloudflare's `trim=top;right;bottom;left` parameter to extract a vertical band from the full screenshot. If total results are capped during scan, `total_estimated: true` is included. **Errors:** - 400 `bad_request` — missing or invalid `type` - 404 `not_found` — section type slug doesn't exist ## Search ### `GET /v1/search` Free-text search with grouped results. Tokenises the query and resolves it to filters in tiers: 1. **Phrase containment** — for multi-word term names that wouldn't be hit by per-token matching. If the normalized query (lowercase + alphanumeric-only + space-separated) contains a multi-word term name as a word-boundary substring, that term becomes a `matched_filter`. Single-word names skip this tier (they go through tier 2). Per taxonomy: longest term name wins; ties broken by highest count. Applied to `genre, color, style, sections, tech, platform, typeface, post_tag` (post_tag still subject to the ≥10-site threshold). Examples: `how it works` → `sections/How It Works` (164); `pricing table` → `sections/Pricing Table` (303); `twitter feed` → `post_tag/Twitter Feed` (387). Without this tier those queries fail the prefix floor in tier 2 because each individual token is too short relative to the slug. 2. **Primary taxonomies (per-token)** — `genre`, `color`, `style`, `sections`, `tech`, `platform`, `typeface`. Best match per token becomes a `matched_filter`. Match logic is **exact-preferred**: if any term has a slug or name exactly equal to the token, only exact matches are considered for that token; otherwise the matcher falls to prefix match (token is a prefix of the slug or name) gated by a length-ratio floor (token must cover ≥50% of the matched term). Within a tier the highest-count term wins. The floor exists to prevent short generic tokens from latching onto long unrelated slugs (e.g. `page` would otherwise prefix-match `pagepiling-js`). Skips taxonomies already claimed by tier 1. 3. **Synonyms** — fills filterable taxonomies the previous tiers didn't claim. Reads each term's curated `synonyms` (see `/v1/taxonomies`) across `post_tag, style, color, genre, pricing, tech, sections`. Match logic: every query token must be a prefix of some synonym token (order-independent). Per-taxonomy single pick, highest-count term wins. `post_tag` synonym matches are still subject to the ≥10-site threshold. 4. **`post_tag`** (name/slug fallback) — tried only if no previous tier matched. Tags with ≥10 sites attached are eligible, same exact-preferred rule as the primary tier. OPL tags are curated editorial labels (effectively features of a site: "coffee", "dark mode", "scroll effects"), so a tag with real coverage is a far better filter than scanning post titles. One-off experimental tags are gated by the threshold to avoid over-narrowing. 5. **`post_title LIKE`** — final fallback when no taxonomy filter resolved. Every taxonomy-typed object in the response includes an absolute `url` field built from the WordPress `register_taxonomy()` rewrite slug. **Use that URL directly — never construct it from `taxonomy` + `slug`.** Two taxonomies do not match their name: - `sections` registers as `/section/` (singular) - `authors` registers as `/authors/` (plural — distinct from WordPress's built-in `/author//` user archive) Response is `Cache-Control: public, max-age=300`. Hot queries serve from CDN. **Query parameters:** | Name | Type | Default | Notes | |-----------|---------|---------|------------------------------------------------------------------------------------------------------| | q | string | — | **Required.** Min 2 chars. See *Sanitization & stop tokens* below for tokenization details. | | limit | integer | 12 | Max 50. Sets the size of `results.items`. The `total` field reflects the unfiltered AND-match count. | | stopwords | string | `all` | Stop-token policy. `all` (default) drops both structural and noise tokens; `structural` drops only grammatical fillers; `off` keeps every token ≥2 chars. | **Sanitization & stop tokens.** The query is lowercased, stripped of punctuation, and split into tokens of ≥2 characters. Two stop-token sets are then dropped (configurable via the `stopwords` query parameter): - **Structural** (always dropped unless `stopwords=off`) — `a, an, the, of, and, or, for, to, in, on, with`. Grammatical fillers; never useful as a search filter. - **Noise** (dropped under default `stopwords=all`) — `website, site, page, template, templates, design, online`. Domain-specific filler that's typed as part of a phrase but adds no signal in the OPL catalog. Users searching `personal website`, `blog template`, or `landing page` mean the genre, not literal title keywords. Stripping these tokens at the source prevents them from polluting `matched_filters` via prefix collisions with longer taxonomy slugs. The returned `tokens` array reflects the post-sanitization token list — what the matcher actually saw. If sanitization empties the token list, the response is a successful 200 with empty `matched_filters` and `results`. API consumers whose vocabulary doesn't match OPL's defaults (e.g. a catalog where "Website" is a meaningful term) should pass `stopwords=structural` to disable noise stripping while keeping grammatical filtering, or `stopwords=off` to disable all stop-token handling. **Tuning rules** are admin-curated routing overrides for queries that should land on dedicated pages instead of taxonomy archives. Example: `framer template` resolves to `tuned_destination: { url: "/framer-templates", ... }` because the `/platform/framer` archive deliberately excludes templates. When `tuned_destination` is non-null, clients should render it prominently above `matched_filters` as the 'best match' for the query. Match logic: every rule token must be a prefix of some query token, order-independent (`framer template`, `framer templates`, `templates framer`, `free framer templates` all match the same rule). **Template archive registry** *(optional / additive)*. OPL maintains a curated list of template-archive landing pages (e.g. `/framer-templates`, `/free-portfolio-templates`, `/webflow-portfolio-templates`, `/tailwind-templates`). Each entry is a slug + title + a set of taxonomy filters that AND-filter inspirations of `category=templates`. After the matcher resolves `matched_filters`, the search walks this registry: - `matched_template_archive` — the entry whose filter set exactly equals `matched_filters` (same taxonomies, same slugs, ignoring order). `null` when no exact match exists. - `similar_template_archives` — up to 20 entries that share ≥1 filter with `matched_filters` and don't contradict any (e.g. for query `framer`, all `framer-X-templates` entries surface here). Sorted by shared-filter count desc, then specificity desc, then registry order. This means consumers can offer template-archive shortcuts in parallel to the inspiration archive — without needing to detect 'template intent' from the raw query. A search for `framer` returns `matched_filter: platform/framer` (the inspiration archive) **plus** `matched_template_archive: /framer-templates` (the template archive) **plus** `similar_template_archives: [/free-framer-templates, /framer-portfolio-templates, ...]` (related template archives). Render them as parallel sections; let the user pick the destination that fits their intent. **These two fields are purely additive — opt out by simply not reading them.** There's no flag to set, no parameter to pass, no auth tier required. Clients that only care about taxonomy archives or the post grid continue to consume `matched_filters` / `matching_terms` / `matching_authors` / `results` exactly as before; the new fields are `null` / `[]` when no curated archive aligns with a query, so unreferenced they cost nothing in the response payload (a few bytes for the empty defaults). Recommended for clients that surface design-resource navigation; safely ignorable for clients building inspiration-only browsers. **Response 200:** ```json { "query": "dark pricing", "tokens": ["dark", "pricing"], "tuned_destination": null, "matched_filters": [ { "taxonomy": "color", "name": "Dark", "slug": "dark", "count": 1418, "url": "/color/dark" }, { "taxonomy": "sections", "name": "Pricing", "slug": "pricing", "count": 314, "url": "/section/pricing" } ], "matched_template_archive": null, "similar_template_archives": [], "matching_terms": [ { "taxonomy": "sections", "name": "Pricing Table", "slug": "pricing-table", "count": 303, "url": "/section/pricing-table" } ], "matching_authors": [], "results": { "total": 70, "items": [ { "title": "Example Site", "slug": "example-site", "url": "https://example.com", "offline": false, "published": "2026-04-15", "genre": ["SaaS"], "sections": ["Pricing", "Hero"], "authors": ["Example Studio"], "screenshot": { "hero": "...", "thumbnail": "...", "full": "..." } } ] } } ``` **Errors:** - 400 `bad_request` — `q` shorter than 2 chars (A query that's empty *after* stop-token removal — e.g. `q=the` or `q=website` under default sanitization — returns a 200 with `tokens: []` and an empty result set, not a 400.) ### Synonyms Each term can have a curated list of synonyms — alt spellings, vernacular, related terms — set editorially per term in wp-admin. Synonyms let common queries route to the correct archive without hardcoding a synonym map in your client. **Where they live:** every term in `/v1/taxonomies` includes a `synonyms` array (empty `[]` if none set). Synonyms are exposed for `post_tag, style, color, genre, pricing, tech, sections`. ```json { "name": "Pink", "slug": "pink", "count": 273, "synonyms": ["magenta", "coral pink"] } { "name": "Red", "slug": "red", "count": 396, "synonyms": ["coral", "crimson"] } { "name": "Dark", "slug": "dark", "count": 1419, "synonyms": ["dark mode", "dark theme"] } { "name": "Blue", "slug": "blue", "count": 909, "synonyms": ["navy", "cobalt", "azure", "indigo"] } { "name": "Colorful", "slug": "colorful", "count": 941, "synonyms": ["multi-color", "vibrant", "rainbow"] } ``` **Where they're applied:** the `/v1/search` matcher consults synonyms as Tier 2 (after primary name/slug match, before the post_tag fallback). When a query matches a term via synonym, that term becomes a `matched_filter` indistinguishable from a name-matched one — same response shape, same AND-filtering of results. The matched-via mechanism is intentionally opaque to consumers. **Match logic:** every query token must be a prefix of some synonym token. Order-independent. Mirrors the search tuning rules matcher — predictable, no false positives from substring scanning. | Query | Synonym | Match? | |---|---|---| | `coral` | `"coral pink"` | ✓ (`coral` is prefix of `coral`) | | `coral pink` | `"coral pink"` | ✓ (both tokens prefix-match) | | `pink coral` | `"coral pink"` | ✓ (order-independent) | | `coral blue` | `"coral pink"` | ✗ (`blue` has no prefix in `pink`) | | `co` | `"coral pink"` | ✓ (`co` is prefix of `coral`) | | `coralx` | `"coral"` | ✗ (synonym token isn't a prefix of query token) | **Tie-breaking:** if two terms in the same taxonomy both synonym-match a query, the term with the higher post count wins. Example: `coral` matches both `Red` (synonym `"coral"`, 396 sites) and `Pink` (synonym `"coral pink"`, 273 sites) → `Red` is returned. **Editorial workflow:** edit any term in wp-admin (e.g. *Color → Pink → Synonyms: "magenta, coral pink"*). Synonyms are comma-separated; whitespace-trimmed. Stored as ACF text on `wp_termmeta(meta_key='synonyms')`. Live in the API immediately; the on-site search modal's PHP proxy has a 5-min transient per query, so changes can take up to 5 min to surface for queries that are currently warm in cache. Direct API consumers see edits instantly. **Discovery:** `GET /v1/taxonomies?taxonomy=color` returns every term with its synonyms array — use this to surface "did you mean…" hints, build query autocomplete, or audit the synonym map. ## Taxonomies ### `GET /v1/taxonomies` List every term in every (or one) taxonomy with post counts, archive URLs, and synonyms. Use this to populate filter UIs or discover valid filter values. **Query parameters:** | Name | Type | Default | Notes | |----------|--------|---------|---------------------------------------------------------------------------------| | taxonomy | string | — | Optional filter — one of `genre`, `color`, `style`, `typeface`, `platform`, `tech`, `sections`, `authors` | **Response 200:** ```json { "color": [ { "name": "Dark", "slug": "dark", "count": 3129, "url": "/color/dark", "synonyms": ["black", "night"] }, { "name": "Colorful", "slug": "colorful", "count": 1842, "url": "/color/colorful", "synonyms": [] } ], "sections": [ { "name": "Team", "slug": "team", "count": 309, "url": "/section/team", "synonyms": [] } ], "authors": [ { "name": "Manuel Moreale", "slug": "manu", "count": 58, "url": "/authors/manu", "synonyms": [] } ] } ``` Each term's `url` is an absolute path (no domain, no trailing slash) — append to `https://onepagelove.com` to get the full archive URL. If `?taxonomy=color` is supplied, only the `color` key is returned. ## Stats ### `GET /v1/stats/taxonomy` Aggregate term usage within a taxonomy over a time window. Useful for design-trend dashboards. **Query parameters:** | Name | Type | Default | Notes | |----------|--------|---------|------------------------------------------------------------------------------| | taxonomy | string | — | **Required.** One of `genre`, `color`, `style`, `typeface`, `platform`, `tech`, `sections`, `authors` | | period | string | all | `7d` \| `30d` \| `12mo` \| `all` | | sort | string | count | `count` (desc) \| `alpha` | **Response 200:** ```json { "taxonomy": "style", "period": "12mo", "total_tagged": 675, "results": [ { "term": "Scroll Effects", "slug": "scroll-effects", "count": 198, "share": "29.3%" }, { "term": "Illustrative", "slug": "illustrative", "count": 156, "share": "23.1%" } ] } ``` `share` is the term's percentage of `total_tagged`, formatted as a string. ## Screenshot URL reference All screenshot URLs use Cloudflare Image Resizing. The full-size origin is on `onepagelove.com`; section crops are served from `assets.onepagelove.com`. **Preset URL shape (full-page):** ``` https://onepagelove.com/cdn-cgi/image// ``` Where `` is a comma-separated Cloudflare config: | Preset | Params | |-----------|-------------------------------------------------| | hero | `w=1280,h=800,fit=cover,g=top,f=auto,q=85` | | thumbnail | `w=400,h=300,fit=cover,g=top,f=auto,q=85` | | full | `w=2560,f=auto,q=85` | | mobile | `w=375,f=auto,q=85` (uses mobile source if captured) | You can build custom transforms by modifying `` — see Cloudflare's docs at `https://developers.cloudflare.com/images/image-resizing/url-format/`. **Section crop URL:** ``` https://assets.onepagelove.com/cdn-cgi/image/trim=;;;,width=,height=,fit=cover,format=jpg,quality=85/ ``` `trim` pixel values extract a vertical band from the parent screenshot. Height is auto-calculated from the trimmed band and the requested output width. ## Versioning The API is versioned via URL prefix (`/v1`). Breaking changes (field removals, type changes, renamed parameters) will ship under a new prefix (`/v2`) with `/v1` maintained for at least 6 months after `/v2` GA. Additive changes (new fields, new optional parameters, new endpoints) ship in place without a version bump. Every substantive change is recorded in the [changelog](https://api.onepagelove.com/docs/changelog.md). ## Contact - Author: Rob Hope (rob@onepagelove.com) - Landing page: https://onepagelove.com/api - Interactive docs: https://api.onepagelove.com/docs - OpenAPI spec: https://api.onepagelove.com/docs/openapi.json - Terms of use: https://api.onepagelove.com/docs/terms.md