Developer documentation
The Facet Creator API
Read your portfolio, posts, projects, courses, and talks; create and edit content; pull member and analytics data; and get notified the moment something happens on your site. This page explains how, in plain language first, with the exact detail underneath.
Prefer a machine-readable contract? Download the OpenAPI 3.1 specification file (openapi.yaml) and load it into your tool of choice. Throughout this page, API means Application Programming Interface, JSON means JavaScript Object Notation, and an endpoint is one web address you can call.
Getting started: create your first API key
In short: open your admin panel, make a key, choose what it is allowed to do, copy it once, and send it on every request.
An API key is a secret password for your program. You create one in your site's admin panel under Tools → API. When you create a key you choose three things:
- A name — a label so you can recognise the key later (for example, "Zapier" or "my export script").
- Scopes — the permissions the key carries. A scope limits what the key may do. Pick the smallest set that
does the job:
readto read content,writeto create and edit posts and projects, andwebhooksto register webhooks. See the scopes section for the full list. - Allowed origins (optional) —
allowed_originsis a list of websites that are allowed to use the key from a browser. If you set it, a request must come from one of those origins (checked using the browser'sOriginorRefererheader). Leave it empty for server-to-server use.
The full key value is shown only once, right after you
create it. Copy it somewhere safe immediately — you cannot read it again. A
Facet key always starts with fct_live_.
Make your first call
Replace your-site with your Facet subdomain and
paste your key after Bearer. This reads your
published posts:
curl https://your-site.facetcloud.io/api/v1/posts \
-H "Authorization: Bearer fct_live_YOUR_KEY_HERE"A successful response is JSON, shaped like this:
{
"data": [
{
"id": "rec0123456789abc",
"title": "Hello world",
"slug": "hello-world",
"excerpt": "My first post",
"visibility": "public",
"created": "2026-01-01 12:00:00.000Z"
}
],
"total": 1,
"page": 1,
"per_page": 20
}Rotating a key
To rotate a key means to replace it with a new one — do this on a schedule, or right away if a key might have leaked. The safe order is:
- Create a new key with the same scopes.
- Update your program to use the new key.
- Confirm everything still works.
- Delete (revoke) the old key from the admin panel.
You can also set an expiry date on a key, or switch it off with the active flag, without deleting it.
Glossary of terms
Short, plain definitions of the words used on this page.
- API (Application Programming Interface)
- A set of web addresses (endpoints) your program can call to read or change data on your Facet site.
- Endpoint
- A single web address plus method (for example GET /api/v1/posts) that does one job.
- Scope
- A permission stamped on an API key that limits what the key may do, such as read or write.
- Webhook
- A message Facet sends to a URL you choose when something happens, so you do not have to keep asking.
- HMAC (Hash-based Message Authentication Code)
- A signature, computed from the message body and a shared secret, that lets you confirm a webhook really came from Facet and was not changed.
- JSON (JavaScript Object Notation)
- The plain-text data format every request and response on this API uses.
- MCP (Model Context Protocol)
- An open standard that lets AI assistants call tools and APIs. The Facet API is designed to be usable from MCP tools.
- Cursor
- An opaque marker returned with a page of results; pass it back to fetch the next page without missing or repeating rows.
- Bearer token
- A credential sent in the Authorization header that proves who is making the request. Here, your API key is the bearer token.
Authentication: sending your key
In short: put your key in one header on every request. There is no other way to sign in.
Every request must include an Authorization header
with the word Bearer, a space, and your key. A bearer token is just a credential that proves who is calling.
Authorization: Bearer fct_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxThere is no X-API-Key header. The Authorization: Bearer header above is the only
accepted credential. Requests are made over HTTPS only; never put your key
in a URL or in client-side code you ship to visitors.
If the header is missing or the key is wrong, you get a 401. If the key is real but not allowed to do what
you asked, you get a 403. See error codes.
Scopes: what a key may do
In short: a scope is a permission on the key. The endpoint you call checks that your key has the right one, or it returns 403.
The admin "create key" form accepts three scope values:
read— read your published content (the v1 read endpoints and webhook polling).write— create, update, and delete posts, projects, courses, talks, experience, education, skills, and certifications (the v1 write endpoints).webhooks— register outbound webhooks.
The newer v2 endpoints check more specific, namespaced scope strings: read:content, read:members, read:analytics, and write:webhooks. The exact scope each endpoint
requires is listed beside it in the endpoint reference and in the
downloadable OpenAPI file. Give each key only the scopes it needs.
Pagination: reading long lists
In short: v1 lists hand you a cursor to fetch the next page; v2 lists use a page number. Some short lists return everything at once.
v1 lists use a cursor
The v1 list endpoints (posts, projects, courses) return up to limit items (default 20, maximum 100) sorted by sort (one of created, -created, updated, -updated, title, -title; default -created).
A cursor is an opaque marker. When more results exist, the response
includes next_cursor; pass it back as the cursor query value to get the next page. This is
reliable even when two records share a timestamp.
curl "https://your-site.facetcloud.io/api/v1/posts?limit=20&cursor=CURSOR_FROM_LAST_RESPONSE" \
-H "Authorization: Bearer fct_live_YOUR_KEY_HERE"v2 lists use a page number
The v2 list endpoints use page (starting at 1) and per_page (default 50, maximum 200). The response
wraps results in an items array and echoes page and per_page.
Simple lists return everything
Experience, education, skills, and certifications return their full list
(no paging) inside a data array.
Endpoint reference (every path)
In short: every path you can call, grouped by what it touches, with the scope it needs.
All paths are relative to your site, for example https://your-site.facetcloud.io. A {id} is a record id; a {idOrSlug} is read as a slug by GET and as a record id by PATCH and DELETE.
Profile
The site owner's public profile.
| Method | Path | What it does | Scope |
|---|---|---|---|
GET | /api/v1/profile | Get the owner's public profile. | read |
Posts
Blog and newsletter posts. You can read them on v1 and v2, and create, edit, and delete them on v1.
| Method | Path | What it does | Scope |
|---|---|---|---|
GET | /api/v1/posts | List published posts (cursor pagination). | read |
GET | /api/v1/posts/{idOrSlug} | Get one published post by its slug. | read |
POST | /api/v1/posts | Create a post. | write |
PATCH | /api/v1/posts/{idOrSlug} | Update a post by its record id. | write |
DELETE | /api/v1/posts/{idOrSlug} | Delete a post by its record id. | write |
GET | /api/v2/posts | List posts (page pagination). | read:content |
GET | /api/v2/posts/{id} | Get one post by record id. | read:content |
Projects
Portfolio projects. Same shape as posts: readable on v1 and v2, writable on v1.
| Method | Path | What it does | Scope |
|---|---|---|---|
GET | /api/v1/projects | List published projects (cursor pagination). | read |
GET | /api/v1/projects/{idOrSlug} | Get one published project by its slug. | read |
POST | /api/v1/projects | Create a project. | write |
PATCH | /api/v1/projects/{idOrSlug} | Update a project by its record id. | write |
DELETE | /api/v1/projects/{idOrSlug} | Delete a project by its record id. | write |
GET | /api/v2/projects | List projects (page pagination). | read:content |
GET | /api/v2/projects/{id} | Get one project by record id. | read:content |
Courses
Course metadata (titles, prices, descriptions). Lessons are not exposed over the API.
| Method | Path | What it does | Scope |
|---|---|---|---|
GET | /api/v1/courses | List courses (cursor pagination). | read |
POST | /api/v1/courses | Create a course. | write |
PATCH | /api/v1/courses/{id} | Update a course by its record id. | write |
DELETE | /api/v1/courses/{id} | Delete a course by its record id. | write |
GET | /api/v2/courses | List courses (page pagination). | read:content |
Talks
Talks and speaking engagements.
| Method | Path | What it does | Scope |
|---|---|---|---|
GET | /api/v1/talks | List talks. | read |
POST | /api/v1/talks | Create a talk. | write |
PATCH | /api/v1/talks/{id} | Update a talk by its record id. | write |
DELETE | /api/v1/talks/{id} | Delete a talk by its record id. | write |
GET | /api/v2/talks | List talks (page pagination). | read:content |
Portfolio
Experience, education, skills, and certifications. These return the full list with no pagination.
| Method | Path | What it does | Scope |
|---|---|---|---|
GET | /api/v1/experience | List work experience. | read |
POST | /api/v1/experience | Create an experience entry. | write |
PATCH | /api/v1/experience/{id} | Update an experience entry by its record id. | write |
DELETE | /api/v1/experience/{id} | Delete an experience entry by its record id. | write |
GET | /api/v1/education | List education. | read |
POST | /api/v1/education | Create an education entry. | write |
PATCH | /api/v1/education/{id} | Update an education entry by its record id. | write |
DELETE | /api/v1/education/{id} | Delete an education entry by its record id. | write |
GET | /api/v1/skills | List skills. | read |
POST | /api/v1/skills | Create a skill. | write |
PATCH | /api/v1/skills/{id} | Update a skill by its record id. | write |
DELETE | /api/v1/skills/{id} | Delete a skill by its record id. | write |
GET | /api/v1/certifications | List certifications. | read |
POST | /api/v1/certifications | Create a certification. | write |
PATCH | /api/v1/certifications/{id} | Update a certification by its record id. | write |
DELETE | /api/v1/certifications/{id} | Delete a certification by its record id. | write |
Members
Member accounts on your site (v2 only).
| Method | Path | What it does | Scope |
|---|---|---|---|
GET | /api/v2/members | List member accounts (page pagination). | read:members |
Analytics
Your site's analytics (v2 only). The exact fields depend on your plan.
| Method | Path | What it does | Scope |
|---|---|---|---|
GET | /api/v2/analytics | Get site analytics. | read:analytics |
Webhooks
Register where events should be sent, poll for events, and register outbound subscriptions.
| Method | Path | What it does | Scope |
|---|---|---|---|
GET | /api/v1/webhooks/events | List the event types you can poll for. | read |
GET | /api/v1/webhooks/poll/{event} | Poll for events of one type. | read |
POST | /api/v2/webhooks | Register an outbound webhook. | write:webhooks |
API keys (admin)
Create and manage your keys. These endpoints are called by your own admin panel and need an owner login, not an API key.
| Method | Path | What it does | Scope |
|---|---|---|---|
POST | /api/admin/api-keys | Create an API key (full value shown once). | owner login |
GET | /api/admin/api-keys | List API keys (prefix only). | owner login |
PATCH | /api/admin/api-keys/{id} | Update a key's name, scopes, origins, or expiry. | owner login |
DELETE | /api/admin/api-keys/{id} | Revoke (delete) a key. | owner login |
POST | /api/admin/api-keys/{id}/rotate | Rotate a key's secret — issues a new fct_live_* value (shown once); old secret valid 24 h. | owner login |
Webhooks: getting notified of events
In short: a webhook is a message Facet sends to a URL you choose whenever something happens, so you do not have to keep asking. Every message is signed so you can trust it.
You have two ways to receive events. Push: register a URL and Facet sends each event to it as it happens. Poll: ask for recent events on a schedule (handy for tools like Zapier, n8n, Make, or Pipedream). Both deliver the same events.
The event catalog
Facet emits these nine events:
| Event | When it fires | Main data fields |
|---|---|---|
content.created | A post, project, or custom page is created. | id, collection, title, slug |
content.updated | A post, project, or custom page is updated. | id, collection, title, slug |
content.deleted | A post, project, or custom page is deleted. | id, collection, title, slug |
content.published | Content is updated while not a draft. May fire alongside content.updated; de-duplicate on your side. | id, collection, title, slug |
purchase.completed | A one-time purchase is completed. | id, collection, buyer_email, content_type, content_id, amount, currency |
course.enrolled | A learner enrolls in a course. | id, collection, course, buyer_email |
course.completed | A learner finishes a course. | id, collection, course, buyer_email, completed_at |
testimonial.submitted | A testimonial is submitted. | id, collection, name, email, company, role |
newsletter.subscribe | Someone subscribes to your newsletter. | (subscriber details) |
What a delivery looks like
Each delivery is an HTTP POST with a JSON body and
these headers: X-Facet-Event (the event name), X-Facet-Delivery (a unique id for this delivery),
and X-Facet-Signature (the signature, described
below). The body looks like this:
{
"event": "purchase.completed",
"timestamp": "2026-01-01T12:00:00Z",
"data": {
"id": "rec0123456789abc",
"collection": "purchases",
"buyer_email": "[email protected]",
"content_type": "course",
"content_id": "rec9876543210xyz",
"amount": 4900,
"currency": "usd"
}
}Verify the signature
Always check the signature before you trust a delivery. HMAC (Hash-based Message Authentication Code) is a signature computed from the
exact request body and your subscription's secret. Facet sends it in the X-Facet-Signature header as sha256= followed by a hex value. Compute the same
thing yourself and compare; if they match, the message is authentic and
unaltered. Use a constant-time comparison to avoid timing attacks.
import crypto from "node:crypto";
// secret = the subscription secret Facet gave you.
// header = the value of the X-Facet-Signature request header.
// rawBody = the exact bytes of the request body (do NOT re-serialise).
function verifyFacetSignature(secret, header, rawBody) {
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(header ?? "", "utf8");
const b = Buffer.from(expected, "utf8");
// Lengths must match before timingSafeEqual, or it throws.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express: capture the raw body so the bytes are unchanged.
// app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));
app.post("/facet-webhook", (req, res) => {
const ok = verifyFacetSignature(
process.env.FACET_WEBHOOK_SECRET,
req.header("X-Facet-Signature"),
req.rawBody
);
if (!ok) return res.status(401).send("bad signature");
// ...handle req.body...
res.sendStatus(200);
});sha256= in the header):# Pipe the EXACT raw body to openssl and key it with your secret.
printf '%s' "$RAW_BODY" \
| openssl dgst -sha256 -hmac "$FACET_WEBHOOK_SECRET"
# -> (stdin)= 3f5b... compare with the X-Facet-Signature header valueRetries and failures (push)
Respond with any 2xx status to acknowledge a
delivery. If your endpoint returns a non-2xx status
or times out, Facet retries on a back-off schedule: 1 minute, then 5 minutes, 30 minutes, 2 hours, and 12 hours (five retries, so six attempts in total). After 10 consecutive failures the subscription is switched off
automatically; re-enable it from the admin panel once your endpoint is
healthy. You can also send a test delivery and review delivery history from
the admin panel.
Polling instead
To poll, call GET /api/v1/webhooks/poll/{event} with your read-scoped key. By default you get the
newest events first; pass the cursor from a previous
response to page through them, or pass since (an
RFC 3339 timestamp) to replay oldest-first. Each event carries a stable id so you can avoid handling it twice. Polling is
capped at 20 requests per minute per key, and events are kept for 7 days.
curl "https://your-site.facetcloud.io/api/v1/webhooks/poll/purchase.completed?limit=50" \
-H "Authorization: Bearer fct_live_YOUR_KEY_HERE"Error codes and what they mean
In short: every error is JSON with an error object
carrying a code (stable string) and a message (human-readable). The status code tells you
the category.
Whenever a request fails, the body is a JSON object: { "error": { "code": "…", "message": "…" } }.
Here is what each status means.
| Status | Meaning | Example body |
|---|---|---|
400 Bad Request | The request body or a parameter was malformed. | { "error": { "code": "invalid_request", "message": "invalid request body" } } |
401 Unauthorized | The Authorization header is missing or malformed, or the key is unknown. | { "error": { "code": "unauthorized", "message": "missing or invalid Authorization header" } } |
403 Forbidden | The key is valid but disabled, expired, lacks the required scope, or the origin is not allowed. | { "error": { "code": "insufficient_scope", "message": "insufficient scope" } } |
404 Not Found | The resource does not exist or is not public. | { "error": { "code": "not_found", "message": "not found" } } |
429 Too Many Requests | You hit the rate limit. Read the Retry-After header (seconds). | { "error": { "code": "rate_limited", "message": "too many requests" } } |
500 Internal Server Error | Something failed on our side, for example a query error. | { "error": { "code": "internal_error", "message": "query failed" } } |
503 Service Unavailable | Webhook storage is not provisioned on this site yet. | { "error": { "code": "not_provisioned", "message": "webhooks_not_provisioned" } } |
Rate limits: how often you can call
In short: each tier sets how many requests per minute you get. Read the response headers to track your remaining budget.
Limits are counted per client IP address. A short burst above the steady rate is allowed (the burst column). Every response carries these headers:
X-RateLimit-Limit— the maximum (the burst size).X-RateLimit-Remaining— how many you have left right now.X-RateLimit-Reset— a Unix timestamp (seconds) when the window resets.Retry-After— only on a429: how many seconds to wait.
| Tier | Rate | Burst | Applies to |
|---|---|---|---|
| Normal | 60 requests / minute | 10 | All public read and write endpoints (v1 and v2). |
| Moderate | 10 requests / minute | 5 | Admin key and webhook management endpoints. |
| Strict | 5 requests / minute | 3 | Sensitive endpoints such as password change (not part of this API). |
| Poll cap | 20 polls / minute / key | — | Extra cap on GET /api/v1/webhooks/poll/{event}, on top of the Normal tier. |
When you hit a limit you get a 429 with { "error": { "code": "rate_limited", "message": "too many requests" } }.
Wait the number of seconds in Retry-After, then try again.
Need the exact machine-readable contract? The OpenAPI 3.1 file is the source of truth for paths, parameters, schemas, and scopes.