Create a personal access token

Embed
Preview of the card
<a href="https://forumulate.com/reference/forumulate-api-pat-create"><img src="https://forumulate.com/reference/_api/public/orgs/046a9bc0-d530-43ae-9bae-af7945008cda/spaces/8042f004-e3d9-445f-b7a4-b6c1f86836e6/pages/9c6689ec-7103-4d43-b0b5-208857613c61/social-card.svg?v=3e7ae33eec8b" alt="Create a personal access token" width="600" height="315"></a>
POSThttps://forumulate.com/api/pats

Mints a long-lived credential for server-to-server use and returns the secret ONCE, in this response, which is the only time it exists in readable form. Reach for it instead of replaying a session cookie from a browser.

Requires token scope: admin (the coarse method gate) for Atlas key (ak_), Personal Access Token (cft_) or OAuth (cfo_) auth — in addition to any catalog scope (GET /pats/scopes) the route’s capability needs. Session/cookie auth is governed by RBAC capabilities instead.

Parameters
forumulate-versionstringheader
API version negotiation. Send forumulate-version: 2026-12-01 on every request from a new integration; it is optional only so that integrations written before versioning keep working. Each version includes everything before it:
Idempotency-Keystringheader
Makes this write safe to retry. Optional — send any unique string (a UUID is ideal) and the first request runs while its response is remembered under that key for 24 hours; an identical retry replays that same status and body instead of acting twice, so a timeout or a 5xx from the edge can be retried without creating a second row. A retry sent while the first request is still in flight is refused with 409. The key is scoped to the method and path, so the same key on a different endpoint is a different request. Error responses of 5xx are never cached — those retries re-run. A key is bound to the BODY it was first used with: reusing it with a different body is a 409 idempotency.key_reused, not a replay, because replaying would tell you a write landed that never ran. Generate one key per request, never per process or per session.
Request body application/json
namestringrequired
What this token is for, so a list of them is readable a year later.
scopesstring[]required
The coarse HTTP gate: read to read, write to author, admin to issue further tokens. Required. A catalog name (docs:read) sent here is accepted and MOVED to scopeGrants, so reading the token back shows only the gate it derived — not what you sent. Recommended form: scopes (the coarse read/write/admin gate) + capabilities (exactly what the token may do, from the capability enum) + constraints (exactly where: org, space and category ids). A request outside either answers 403. scopeGrants (catalog names that expand into capabilities) and the top-level spaceIds (the same thing as constraints.spaceIds) are LEGACY: still honoured, no removal date, but new integrations should not need them.
expiresInDaysinteger
Expire it after this many days. Omit for a token that never expires on its own.
spaceIdsstring[]deprecated
LEGACY — use constraints.spaceIds, which means the same and sits with the other resource pins. Still honoured, no removal date. The spaces this token may touch; omit for everywhere its owner can go.
capabilitiesstring[]
RECOMMENDED. Exactly the capabilities this token may exercise (e.g. docs.write, org.manage_members), for least privilege. Omit for a token that carries the whole of its owner’s reach. Enforced on top of the scope and the owner’s grants — the token is their intersection — and stored exactly as sent, so the read-back matches.
scopeGrantsstring[]deprecated
LEGACY — name capabilities directly. Still honoured, no removal date. Named scopes from the published catalog (GET /pats/scopes), e.g. docs:read; each EXPANDS into a bundle of capabilities, and the union is what is stored and enforced as capabilities.
constraintsPatConstraintsDto
RECOMMENDED. Pin the token to specific org/space/category ids. A request targeting a resource outside the set is denied (403) even when the scope and the owner’s grants would allow it. Omit for an unconstrained token.
Show child parameters
spaceIdsstring[]
Only these spaces.
categoryIdsstring[]
Only these forum categories.
orgIdsstring[]
Only these organizations.
{
  "name": "string",
  "scopes": [
    "read"
  ],
  "expiresInDays": 0,
  "spaceIds": [
    "string"
  ],
  "capabilities": [
    "string"
  ],
  "scopeGrants": [
    "docs:read"
  ],
  "constraints": {
    "spaceIds": [
      "string"
    ],
    "categoryIds": [
      "string"
    ],
    "orgIds": [
      "string"
    ]
  }
}
Code samples
cURL
curl -X POST "https://forumulate.com/api/pats" \
  -H "forumulate-version: <value>" \
  -H "Idempotency-Key: <value>" \
  -H "Content-Type: application/json" \
  -d '{"name":"string","scopes":["read"],"expiresInDays":0,"spaceIds":["string"],"capabilities":["string"],"scopeGrants":["docs:read"],"constraints":{"spaceIds":["string"],"categoryIds":["string"],"orgIds":["string"]}}'
TypeScript
const res = await fetch('https://forumulate.com/api/pats', {
  method: 'POST',
  headers: {
    'forumulate-version': '<value>',
    'Idempotency-Key': '<value>',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({"name":"string","scopes":["read"],"expiresInDays":0,"spaceIds":["string"],"capabilities":["string"],"scopeGrants":["docs:read"],"constraints":{"spaceIds":["string"],"categoryIds":["string"],"orgIds":["string"]}}),
});
const data = await res.json();
Python
import requests

res = requests.request(
    "POST",
    "https://forumulate.com/api/pats",
    headers={
    "forumulate-version": "<value>",
    "Idempotency-Key": "<value>",
    "Content-Type": "application/json",
    },
    json={"name": "string", "scopes": ["read"], "expiresInDays": 0, "spaceIds": ["string"], "capabilities": ["string"], "scopeGrants": ["docs:read"], "constraints": {"spaceIds": ["string"], "categoryIds": ["string"], "orgIds": ["string"]}},
)
data = res.json()
Go
package main

import (
	"fmt"
	"io"
	"net/http"
	"strings"
)

func main() {
	req, _ := http.NewRequest("POST", "https://forumulate.com/api/pats", strings.NewReader(`{"name":"string","scopes":["read"],"expiresInDays":0,"spaceIds":["string"],"capabilities":["string"],"scopeGrants":["docs:read"],"constraints":{"spaceIds":["string"],"categoryIds":["string"],"orgIds":["string"]}}`))
	req.Header.Set("forumulate-version", "<value>")
	req.Header.Set("Idempotency-Key", "<value>")
	req.Header.Set("Content-Type", "application/json")
	res, _ := http.DefaultClient.Do(req)
	defer res.Body.Close()
	body, _ := io.ReadAll(res.Body)
	fmt.Println(string(body))
}
PHP
<?php
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://forumulate.com/api/pats');
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['forumulate-version: <value>', 'Idempotency-Key: <value>', 'Content-Type: application/json']);
curl_setopt($ch, CURLOPT_POSTFIELDS, '{"name":"string","scopes":["read"],"expiresInDays":0,"spaceIds":["string"],"capabilities":["string"],"scopeGrants":["docs:read"],"constraints":{"spaceIds":["string"],"categoryIds":["string"],"orgIds":["string"]}}');
$response = curl_exec($ch);
curl_close($ch);
echo $response;
Ruby
require 'net/http'
require 'uri'

uri = URI('https://forumulate.com/api/pats')
req = Net::HTTP::Post.new(uri)
req['forumulate-version'] = '<value>'
req['Idempotency-Key'] = '<value>'
req['Content-Type'] = 'application/json'
req.body = '{"name":"string","scopes":["read"],"expiresInDays":0,"spaceIds":["string"],"capabilities":["string"],"scopeGrants":["docs:read"],"constraints":{"spaceIds":["string"],"categoryIds":["string"],"orgIds":["string"]}}'
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(req)
end
puts res.body
Java
import java.net.URI;
import java.net.http.*;
import java.net.http.HttpRequest.BodyPublishers;

HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://forumulate.com/api/pats"))
    .header("forumulate-version", "<value>")
    .header("Idempotency-Key", "<value>")
    .header("Content-Type", "application/json")
    .method("POST", BodyPublishers.ofString("{\"name\":\"string\",\"scopes\":[\"read\"],\"expiresInDays\":0,\"spaceIds\":[\"string\"],\"capabilities\":[\"string\"],\"scopeGrants\":[\"docs:read\"],\"constraints\":{\"spaceIds\":[\"string\"],\"categoryIds\":[\"string\"],\"orgIds\":[\"string\"]}}"))
    .build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
Responses
201 The new token, plus its summary. token is the ONLY time the secret is readable — it is stored hashed and can never be shown again; a lost token is rotated, not recovered. This 201 is a RESULT, not a create: no addressable resource was made, so there is no data mirror and nothing to GET back. An acknowledgement, a computed answer, a tally or a one-time credential — read the keys this response documents.
Example
{
  "token": "string",
  "pat": {
    "id": "string",
    "name": "string",
    "scopes": [
      null
    ],
    "createdAt": "2024-01-01T00:00:00Z",
    "expiresAt": null,
    "lastUsedAt": null,
    "spaceIds": [
      null
    ],
    "capabilities": [
      null
    ],
    "scopeGrants": null,
    "constraints": null,
    "applicationName": null,
    "kind": "string",
    "secret": "string"
  },
  "warnings": [
    "tags: \"Launch Day\" is stored as \"launch-day\""
  ]
}
Schema
tokenstringrequired
The secret. Shown once, on this response, and never again — also on pat.secret (and data.secret). Send it as Authorization: Bearer <token>.
patMintedPatSummaryDtorequired
The token row, with secret on it. data is the same object.
Show child parameters
idstringrequired
namestringrequired
scopesstring[]required
The coarse gate only (read/write/admin). Catalog names sent in scopes on create are stored as scopeGrants, so this is not necessarily what you sent — capabilities and constraints are what the token is actually limited by.
createdAtstringrequired
expiresAtstringrequired
lastUsedAtstringrequired
When it was last used to make a request.
spaceIdsstring[]
The spaces it is confined to, when it is.
capabilitiesstring[]
The capabilities it is narrowed to, when it is.
scopeGrantsarray
The named catalog scopes this token was created with (docs:read, …), or null if it named none. Descriptive: capabilities above is what is enforced, so re-pointing a scope name at a different bundle can never widen a token that already exists.
constraintsPatConstraintsDto
Resource-scoped constraints, when set.
Show child parameters
spaceIdsstring[]
Only these spaces.
categoryIdsstring[]
Only these forum categories.
orgIdsstring[]
Only these organizations.
applicationNameobject
The app holding this key, when it is a device key.
kindstringrequired
'pat' or 'device'.
secretstringrequired
The secret — the same string as the top-level token, on the object you are likely to keep. Present on this answer ONLY: every later read of the token is a summary, because the secret is stored hashed. Send it as Authorization: Bearer <secret>.
warningsstring[]
Values we ACCEPTED and then rewrote, one sentence each — a slugified tag, a normalised colour, a clamped range, a slug that was taken. Absent when nothing was rewritten, never []. The same sentences are on the X-Field-Warnings response header. If this is present, something you sent is not what is stored; read the resource back rather than assuming your own value.
400 The request could not be understood — a malformed header, path or query value (request.malformed). A value that was understood but is not acceptable is a 422 validation.failed.
Example
{
  "error": {
    "code": "string",
    "message": "string",
    "details": {},
    "docs": "string",
    "reason": "string",
    "slugs": [
      "string"
    ],
    "conflict": {
      "etag": "string",
      "ops": [
        null
      ]
    }
  },
  "requestId": "string"
}
Schema
errorobjectrequired
Show child parameters
codestringrequired
Stable, machine-readable error code, e.g. rbac.forbidden.
messagestringrequired
Human-readable explanation.
detailsobject
Field-level validation problems, keyed by field path (422 only).
docsstring
Where the vocabulary or format this error is about is published — an absolute URL, or an API route such as GET /space-types/{type}/presets. Present on errors over a controlled list, so the answer to "what am I allowed to send?" is in the error itself.
reasonstring
Why a credential was refused, as one stable word, where one status has several causes — e.g. an external_jwt redeem answers iat_too_old, iat_in_future, bad_signature, bad_issuer, bad_audience, expired and more. Branch on this, not on message.
slugsstring[]
The exact slugs that collided, on a slug-conflict 409 — space.slug_taken, page.slug_taken, collection.slug_taken, site.slug_taken or badge.slug_taken.
conflictobject
On a 409 from a content entry field write (PATCH …/entries/{entryId}/fields) whose If-Match was stale.
Show child parameters
etagstringrequired
The entry's current fields etag.
opsarrayrequired
The field operations since your If-Match that touch a path your own operations touch. Empty: none did — retry the batch unchanged under etag. null: the history no longer reaches your tag — re-read the entry first.
requestIdstring
The id of this request — the same value as the x-request-id and x-forumulate-request-id response headers. Quote it in a bug report: it is the only thing that finds your exact request in our logs. It is in the body because that is the part that survives — an SDK that throws the parsed body, a serverless log that keeps the body and drops the headers, a ticket with the JSON pasted in.
401 No credential, or one that is expired or revoked (auth.required). Send a Bearer token or the session cookie.
Example
{
  "error": {
    "code": "string",
    "message": "string",
    "details": {},
    "docs": "string",
    "reason": "string",
    "slugs": [
      "string"
    ],
    "conflict": {
      "etag": "string",
      "ops": [
        null
      ]
    }
  },
  "requestId": "string"
}
Schema
errorobjectrequired
Show child parameters
codestringrequired
Stable, machine-readable error code, e.g. rbac.forbidden.
messagestringrequired
Human-readable explanation.
detailsobject
Field-level validation problems, keyed by field path (422 only).
docsstring
Where the vocabulary or format this error is about is published — an absolute URL, or an API route such as GET /space-types/{type}/presets. Present on errors over a controlled list, so the answer to "what am I allowed to send?" is in the error itself.
reasonstring
Why a credential was refused, as one stable word, where one status has several causes — e.g. an external_jwt redeem answers iat_too_old, iat_in_future, bad_signature, bad_issuer, bad_audience, expired and more. Branch on this, not on message.
slugsstring[]
The exact slugs that collided, on a slug-conflict 409 — space.slug_taken, page.slug_taken, collection.slug_taken, site.slug_taken or badge.slug_taken.
conflictobject
On a 409 from a content entry field write (PATCH …/entries/{entryId}/fields) whose If-Match was stale.
Show child parameters
etagstringrequired
The entry's current fields etag.
opsarrayrequired
The field operations since your If-Match that touch a path your own operations touch. Empty: none did — retry the batch unchanged under etag. null: the history no longer reaches your tag — re-read the entry first.
requestIdstring
The id of this request — the same value as the x-request-id and x-forumulate-request-id response headers. Quote it in a bug report: it is the only thing that finds your exact request in our logs. It is in the body because that is the part that survives — an SDK that throws the parsed body, a serverless log that keeps the body and drops the headers, a ticket with the JSON pasted in.
403 Authenticated, and not allowed — always rbac.forbidden, whether what is missing is the capability, the token scope, or a resource the token is pinned away from. The message names the capability and, for a scope refusal, the scopes that would grant it. A refusal because of the PLAN is a 402 with quota.exceeded, not this.
Example
{
  "error": {
    "code": "string",
    "message": "string",
    "details": {},
    "docs": "string",
    "reason": "string",
    "slugs": [
      "string"
    ],
    "conflict": {
      "etag": "string",
      "ops": [
        null
      ]
    }
  },
  "requestId": "string"
}
Schema
errorobjectrequired
Show child parameters
codestringrequired
Stable, machine-readable error code, e.g. rbac.forbidden.
messagestringrequired
Human-readable explanation.
detailsobject
Field-level validation problems, keyed by field path (422 only).
docsstring
Where the vocabulary or format this error is about is published — an absolute URL, or an API route such as GET /space-types/{type}/presets. Present on errors over a controlled list, so the answer to "what am I allowed to send?" is in the error itself.
reasonstring
Why a credential was refused, as one stable word, where one status has several causes — e.g. an external_jwt redeem answers iat_too_old, iat_in_future, bad_signature, bad_issuer, bad_audience, expired and more. Branch on this, not on message.
slugsstring[]
The exact slugs that collided, on a slug-conflict 409 — space.slug_taken, page.slug_taken, collection.slug_taken, site.slug_taken or badge.slug_taken.
conflictobject
On a 409 from a content entry field write (PATCH …/entries/{entryId}/fields) whose If-Match was stale.
Show child parameters
etagstringrequired
The entry's current fields etag.
opsarrayrequired
The field operations since your If-Match that touch a path your own operations touch. Empty: none did — retry the batch unchanged under etag. null: the history no longer reaches your tag — re-read the entry first.
requestIdstring
The id of this request — the same value as the x-request-id and x-forumulate-request-id response headers. Quote it in a bug report: it is the only thing that finds your exact request in our logs. It is in the body because that is the part that survives — an SDK that throws the parsed body, a serverless log that keeps the body and drops the headers, a ticket with the JSON pasted in.
422 The body was understood and a value is unacceptable (validation.failed). error.details names the field paths.
Example
{
  "error": {
    "code": "string",
    "message": "string",
    "details": {},
    "docs": "string",
    "reason": "string",
    "slugs": [
      "string"
    ],
    "conflict": {
      "etag": "string",
      "ops": [
        null
      ]
    }
  },
  "requestId": "string"
}
Schema
errorobjectrequired
Show child parameters
codestringrequired
Stable, machine-readable error code, e.g. rbac.forbidden.
messagestringrequired
Human-readable explanation.
detailsobject
Field-level validation problems, keyed by field path (422 only).
docsstring
Where the vocabulary or format this error is about is published — an absolute URL, or an API route such as GET /space-types/{type}/presets. Present on errors over a controlled list, so the answer to "what am I allowed to send?" is in the error itself.
reasonstring
Why a credential was refused, as one stable word, where one status has several causes — e.g. an external_jwt redeem answers iat_too_old, iat_in_future, bad_signature, bad_issuer, bad_audience, expired and more. Branch on this, not on message.
slugsstring[]
The exact slugs that collided, on a slug-conflict 409 — space.slug_taken, page.slug_taken, collection.slug_taken, site.slug_taken or badge.slug_taken.
conflictobject
On a 409 from a content entry field write (PATCH …/entries/{entryId}/fields) whose If-Match was stale.
Show child parameters
etagstringrequired
The entry's current fields etag.
opsarrayrequired
The field operations since your If-Match that touch a path your own operations touch. Empty: none did — retry the batch unchanged under etag. null: the history no longer reaches your tag — re-read the entry first.
requestIdstring
The id of this request — the same value as the x-request-id and x-forumulate-request-id response headers. Quote it in a bug report: it is the only thing that finds your exact request in our logs. It is in the body because that is the part that survives — an SDK that throws the parsed body, a serverless log that keeps the body and drops the headers, a ticket with the JSON pasted in.
429 Rate limited — see the RateLimit-* headers.
Example
{
  "error": {
    "code": "string",
    "message": "string",
    "details": {},
    "docs": "string",
    "reason": "string",
    "slugs": [
      "string"
    ],
    "conflict": {
      "etag": "string",
      "ops": [
        null
      ]
    }
  },
  "requestId": "string"
}
Schema
errorobjectrequired
Show child parameters
codestringrequired
Stable, machine-readable error code, e.g. rbac.forbidden.
messagestringrequired
Human-readable explanation.
detailsobject
Field-level validation problems, keyed by field path (422 only).
docsstring
Where the vocabulary or format this error is about is published — an absolute URL, or an API route such as GET /space-types/{type}/presets. Present on errors over a controlled list, so the answer to "what am I allowed to send?" is in the error itself.
reasonstring
Why a credential was refused, as one stable word, where one status has several causes — e.g. an external_jwt redeem answers iat_too_old, iat_in_future, bad_signature, bad_issuer, bad_audience, expired and more. Branch on this, not on message.
slugsstring[]
The exact slugs that collided, on a slug-conflict 409 — space.slug_taken, page.slug_taken, collection.slug_taken, site.slug_taken or badge.slug_taken.
conflictobject
On a 409 from a content entry field write (PATCH …/entries/{entryId}/fields) whose If-Match was stale.
Show child parameters
etagstringrequired
The entry's current fields etag.
opsarrayrequired
The field operations since your If-Match that touch a path your own operations touch. Empty: none did — retry the batch unchanged under etag. null: the history no longer reaches your tag — re-read the entry first.
requestIdstring
The id of this request — the same value as the x-request-id and x-forumulate-request-id response headers. Quote it in a bug report: it is the only thing that finds your exact request in our logs. It is in the body because that is the part that survives — an SDK that throws the parsed body, a serverless log that keeps the body and drops the headers, a ticket with the JSON pasted in.
default Error — the stable { error: { code, message } } envelope.
Example
{
  "error": {
    "code": "string",
    "message": "string",
    "details": {},
    "docs": "string",
    "reason": "string",
    "slugs": [
      "string"
    ],
    "conflict": {
      "etag": "string",
      "ops": [
        null
      ]
    }
  },
  "requestId": "string"
}
Schema
errorobjectrequired
Show child parameters
codestringrequired
Stable, machine-readable error code, e.g. rbac.forbidden.
messagestringrequired
Human-readable explanation.
detailsobject
Field-level validation problems, keyed by field path (422 only).
docsstring
Where the vocabulary or format this error is about is published — an absolute URL, or an API route such as GET /space-types/{type}/presets. Present on errors over a controlled list, so the answer to "what am I allowed to send?" is in the error itself.
reasonstring
Why a credential was refused, as one stable word, where one status has several causes — e.g. an external_jwt redeem answers iat_too_old, iat_in_future, bad_signature, bad_issuer, bad_audience, expired and more. Branch on this, not on message.
slugsstring[]
The exact slugs that collided, on a slug-conflict 409 — space.slug_taken, page.slug_taken, collection.slug_taken, site.slug_taken or badge.slug_taken.
conflictobject
On a 409 from a content entry field write (PATCH …/entries/{entryId}/fields) whose If-Match was stale.
Show child parameters
etagstringrequired
The entry's current fields etag.
opsarrayrequired
The field operations since your If-Match that touch a path your own operations touch. Empty: none did — retry the batch unchanged under etag. null: the history no longer reaches your tag — re-read the entry first.
requestIdstring
The id of this request — the same value as the x-request-id and x-forumulate-request-id response headers. Quote it in a bug report: it is the only thing that finds your exact request in our logs. It is in the body because that is the part that survives — an SDK that throws the parsed body, a serverless log that keeps the body and drops the headers, a ticket with the JSON pasted in.
The Forumulate teamUpdated