Tokens & members
Manage the API tokens and the people (members) of your account. Creating tokens and
managing members is done by a person, so these endpoints are normally used from the web
console. They also work from a script that has logged in with a console session: the
examples use a cookie jar (cookies.txt) plus the session's CSRF token (replace
YOUR_CSRF_TOKEN with the csrf_token from /auth/me), as described in Authentication. An API token can
never create another token.
Members have one of three roles: viewer (read everything), editor (also upload and edit) and owner (also manage members and revoke anyone's tokens). A token can only have scopes its creator's role allows.
The token object
| Field | Type | Description |
|---|---|---|
jti |
string | token id |
name |
string | your label |
scopes |
array | what the token may do |
created_by |
string | email of the member who created it |
created_at, expires_at |
timestamp | lifetime (at most 365 days) |
last_used_at, use_count |
usage | |
revoked_at |
timestamp or null | set when revoked |
The token value itself is only returned once, when the token is created.
List tokens
GET /tokens
Bearer token · scope fw:read
Also available to any API token of the account (fw:read).
curl "https://fw.unitcircle.ca/tokens" \ -H "Authorization: Bearer eyJhbGciOiJFZERTQSIsImtpZCI6IjIwMjYtMTAtMDEtYjItaiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2Z3LnVuaXRjaXJjbGUuY2EiLCJzdWIiOiJzdmM6c2FuZGJveCByZWFkLW9ubHkgKHB1YmxpYykiLCJhdWQiOlsidWNsb2dzZXJ2ZXIiXSwiZXhwIjoxODIyNDE0NTE3LCJuYmYiOjE3OTA4Nzg1MTIsImlhdCI6MTc5MDg3ODUxNywianRpIjoiMDFhMGY4YWQtODkwZi03ODA5LWI3YTgtMDE0MTZlOWM0YTI3IiwidHlwIjoiYXBpIiwiY3VzdCI6InNhbmRib3guZXhhbXBsZSIsImNpZCI6IjAxYTBmOGExLTEzYWMtN2QzNC05NDEyLTMwYjAwOGFkYzk4NyIsInNjcCI6ImZ3OnJlYWQgbG9nZGF0YTpyZWFkIGRldmljZXM6cmVhZCB0YWdzOnJlYWQgc3RhdHM6cmVhZCJ9.IaQSEW8c2zmJWEioQ8PBaezftMriGUzGQhIof_T7HGG4Usjt9dRmhj-xiw_tMuEYcsR7EzLzXyELG0DrdJGADQ"
http -A bearer -a eyJhbGciOiJFZERTQSIsImtpZCI6IjIwMjYtMTAtMDEtYjItaiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2Z3LnVuaXRjaXJjbGUuY2EiLCJzdWIiOiJzdmM6c2FuZGJveCByZWFkLW9ubHkgKHB1YmxpYykiLCJhdWQiOlsidWNsb2dzZXJ2ZXIiXSwiZXhwIjoxODIyNDE0NTE3LCJuYmYiOjE3OTA4Nzg1MTIsImlhdCI6MTc5MDg3ODUxNywianRpIjoiMDFhMGY4YWQtODkwZi03ODA5LWI3YTgtMDE0MTZlOWM0YTI3IiwidHlwIjoiYXBpIiwiY3VzdCI6InNhbmRib3guZXhhbXBsZSIsImNpZCI6IjAxYTBmOGExLTEzYWMtN2QzNC05NDEyLTMwYjAwOGFkYzk4NyIsInNjcCI6ImZ3OnJlYWQgbG9nZGF0YTpyZWFkIGRldmljZXM6cmVhZCB0YWdzOnJlYWQgc3RhdHM6cmVhZCJ9.IaQSEW8c2zmJWEioQ8PBaezftMriGUzGQhIof_T7HGG4Usjt9dRmhj-xiw_tMuEYcsR7EzLzXyELG0DrdJGADQ GET "https://fw.unitcircle.ca/tokens"
import requests
r = requests.get(
"https://fw.unitcircle.ca/tokens",
headers={"Authorization": "Bearer eyJhbGciOiJFZERTQSIsImtpZCI6IjIwMjYtMTAtMDEtYjItaiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2Z3LnVuaXRjaXJjbGUuY2EiLCJzdWIiOiJzdmM6c2FuZGJveCByZWFkLW9ubHkgKHB1YmxpYykiLCJhdWQiOlsidWNsb2dzZXJ2ZXIiXSwiZXhwIjoxODIyNDE0NTE3LCJuYmYiOjE3OTA4Nzg1MTIsImlhdCI6MTc5MDg3ODUxNywianRpIjoiMDFhMGY4YWQtODkwZi03ODA5LWI3YTgtMDE0MTZlOWM0YTI3IiwidHlwIjoiYXBpIiwiY3VzdCI6InNhbmRib3guZXhhbXBsZSIsImNpZCI6IjAxYTBmOGExLTEzYWMtN2QzNC05NDEyLTMwYjAwOGFkYzk4NyIsInNjcCI6ImZ3OnJlYWQgbG9nZGF0YTpyZWFkIGRldmljZXM6cmVhZCB0YWdzOnJlYWQgc3RhdHM6cmVhZCJ9.IaQSEW8c2zmJWEioQ8PBaezftMriGUzGQhIof_T7HGG4Usjt9dRmhj-xiw_tMuEYcsR7EzLzXyELG0DrdJGADQ"},
)
print(r.status_code, r.json())
{
"items": [
{
"jti": "01a0db3f-5adc-7b3e-a76a-671533a68674",
"name": "ci uploader",
"scopes": ["devices:read", "fw:read", "fw:write", "logdata:read", "logdata:write", "stats:read", "tags:read"],
"created_by": "alice@example.com",
"created_at": "2026-09-26T01:05:57.469919Z",
"expires_at": "2026-12-25T01:05:57Z",
"last_used_at": null,
"use_count": 0,
"revoked_at": null
}
]
}
Create a token
POST /tokens
Console session (browser)
Choose a scope bundle — read-only, uploader or full — and/or explicit scopes.
Store the returned token immediately; it is not shown again.
Other errors: asking for a scope your role doesn't have → 403 you cannot grant scope fw:write (exceeds your role); ttl_seconds out of range or no name → 400.
Body parameters (JSON)
namestring required1–100 characters.
bundlestringread-only,uploaderorfull.scopesarray of stringsExplicit scopes, added to the bundle.
ttl_secondsinteger60 to 31536000 (365 days); default 90 days.
curl -X POST "https://fw.unitcircle.ca/tokens" \
-b cookies.txt \
-H "X-CSRF-Token: YOUR_CSRF_TOKEN" \
-H "Content-Type: application/json" \
-d '{"bundle":"uploader","name":"ci uploader","ttl_seconds":7776000}'http --session=./console.json POST "https://fw.unitcircle.ca/tokens" \ X-CSRF-Token:YOUR_CSRF_TOKEN \ 'bundle=uploader' \ 'name=ci uploader' \ 'ttl_seconds:=7776000'
import requests
# Log in (POST /auth/login emails you a code): see /docs/authentication#log-in-from-a-script
session = requests.Session()
session.post("https://fw.unitcircle.ca/auth/verify", json={"email": "you@example.com", "code": "123456"}).raise_for_status()
csrf = session.get("https://fw.unitcircle.ca/auth/me").json()["csrf_token"]
r = session.post(
"https://fw.unitcircle.ca/tokens",
headers={"X-CSRF-Token": csrf},
json={
"bundle": "uploader",
"name": "ci uploader",
"ttl_seconds": 7776000
},
)
print(r.status_code, r.json())
{
"customer": "example.com",
"expires_at": "2026-12-25T01:05:57Z",
"jti": "01a0db3f-5adc-7b3e-a76a-671533a68674",
"name": "ci uploader",
"note": "Store this token now; it is not shown again.",
"scopes": ["devices:read", "fw:read", "fw:write", "logdata:read", "logdata:write", "stats:read", "tags:read"],
"token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImRldi0xIiwi…"
}{"type":"about:blank","title":"Forbidden","status":403,"detail":"API tokens can only be created from a console session (log in with your email)","instance":"/tokens","request_id":"…"}{"type":"about:blank","title":"Forbidden","status":403,"detail":"missing or invalid CSRF token","instance":"/tokens","request_id":"…"}{"type":"about:blank","title":"Bad Request","status":400,"detail":"unknown scope fw:frobnicate","instance":"/tokens","request_id":"…"}
Revoke a token
DELETE /tokens/{jti}
Console session (browser)
The token stops working within 30 seconds. Members can revoke their own tokens; owners can revoke any token of the account. Revoking an already revoked token is not an error.
Path parameters
jtistring requiredToken id.
curl -X DELETE "https://fw.unitcircle.ca/tokens/01a0db3f-5adc-7b3e-a76a-671533a68674" \ -b cookies.txt \ -H "X-CSRF-Token: YOUR_CSRF_TOKEN"
http --session=./console.json DELETE "https://fw.unitcircle.ca/tokens/01a0db3f-5adc-7b3e-a76a-671533a68674" \ X-CSRF-Token:YOUR_CSRF_TOKEN
import requests
# Log in (POST /auth/login emails you a code): see /docs/authentication#log-in-from-a-script
session = requests.Session()
session.post("https://fw.unitcircle.ca/auth/verify", json={"email": "you@example.com", "code": "123456"}).raise_for_status()
csrf = session.get("https://fw.unitcircle.ca/auth/me").json()["csrf_token"]
r = session.delete(
"https://fw.unitcircle.ca/tokens/01a0db3f-5adc-7b3e-a76a-671533a68674",
headers={"X-CSRF-Token": csrf},
)
print(r.status_code, r.text)
(no body)
{"type":"about:blank","title":"Forbidden","status":403,"detail":"only the token's creator or an owner can revoke it","instance":"/tokens/01a0db3f-…","request_id":"…"}{"type":"about:blank","title":"Not Found","status":404,"detail":"token not found","instance":"/tokens/01a0db3f-…","request_id":"…"}
The member object
| Field | Type | Description |
|---|---|---|
email |
string | the person's address — any domain |
domain |
string | your account |
role |
string | viewer, editor or owner |
disabled |
boolean | membership disabled |
user_disabled |
boolean | the person is disabled service-wide |
created_at |
timestamp | when invited |
last_login_at |
timestamp or null | null: invited but never logged in |
The member endpoints need the members:manage permission, which owners have.
curl "https://fw.unitcircle.ca/members" \ -b cookies.txt
http --session=./console.json GET "https://fw.unitcircle.ca/members"
import requests
# Log in (POST /auth/login emails you a code): see /docs/authentication#log-in-from-a-script
session = requests.Session()
session.post("https://fw.unitcircle.ca/auth/verify", json={"email": "you@example.com", "code": "123456"}).raise_for_status()
csrf = session.get("https://fw.unitcircle.ca/auth/me").json()["csrf_token"]
r = session.get(
"https://fw.unitcircle.ca/members",
)
print(r.status_code, r.json())
{
"items": [
{
"email": "alice@example.com",
"domain": "example.com",
"role": "owner",
"disabled": false,
"created_at": "2026-09-25T18:03:49.631373Z",
"last_login_at": "2026-09-26T01:05:57.41496Z",
"user_disabled": false
}
]
}{"type":"about:blank","title":"Forbidden","status":403,"detail":"missing scope members:manage","instance":"/members","request_id":"…"}
Invite a member
POST /members
Console session (browser) · scope members:manage
Access is by invitation only. The address can be at any domain; the person gets an email
with a link to the login page. Inviting someone who is already a member updates their role
(200).
Body parameters (JSON)
emailstring requiredThe person's email address.
rolestringviewer(default),editororowner.
curl -X POST "https://fw.unitcircle.ca/members" \
-b cookies.txt \
-H "X-CSRF-Token: YOUR_CSRF_TOKEN" \
-H "Content-Type: application/json" \
-d '{"email":"bob@contractor.example","role":"editor"}'http --session=./console.json POST "https://fw.unitcircle.ca/members" \ X-CSRF-Token:YOUR_CSRF_TOKEN \ 'email=bob@contractor.example' \ 'role=editor'
import requests
# Log in (POST /auth/login emails you a code): see /docs/authentication#log-in-from-a-script
session = requests.Session()
session.post("https://fw.unitcircle.ca/auth/verify", json={"email": "you@example.com", "code": "123456"}).raise_for_status()
csrf = session.get("https://fw.unitcircle.ca/auth/me").json()["csrf_token"]
r = session.post(
"https://fw.unitcircle.ca/members",
headers={"X-CSRF-Token": csrf},
json={
"email": "bob@contractor.example",
"role": "editor"
},
)
print(r.status_code, r.json())
{"created": true, "domain": "example.com", "email": "bob@contractor.example", "role": "editor"}{"type":"about:blank","title":"Bad Request","status":400,"detail":"email must be valid and role one of viewer, editor, owner","instance":"/members","request_id":"…"}
Update a member
PATCH /members/{email}
Console session (browser) · scope members:manage
Change the role or disable the membership. Either change ends the person's sessions on your account and revokes the API tokens they created for it.
Path parameters
emailstring requiredThe member's email.
Body parameters (JSON)
rolestringviewer,editororowner.disabledbooleantrueto disable.
curl -X PATCH "https://fw.unitcircle.ca/members/bob@contractor.example" \
-b cookies.txt \
-H "X-CSRF-Token: YOUR_CSRF_TOKEN" \
-H "Content-Type: application/json" \
-d '{"role":"viewer"}'http --session=./console.json PATCH "https://fw.unitcircle.ca/members/bob@contractor.example" \ X-CSRF-Token:YOUR_CSRF_TOKEN \ 'role=viewer'
import requests
# Log in (POST /auth/login emails you a code): see /docs/authentication#log-in-from-a-script
session = requests.Session()
session.post("https://fw.unitcircle.ca/auth/verify", json={"email": "you@example.com", "code": "123456"}).raise_for_status()
csrf = session.get("https://fw.unitcircle.ca/auth/me").json()["csrf_token"]
r = session.patch(
"https://fw.unitcircle.ca/members/bob@contractor.example",
headers={"X-CSRF-Token": csrf},
json={
"role": "viewer"
},
)
print(r.status_code, r.json())
{
"email": "bob@contractor.example",
"domain": "example.com",
"role": "viewer",
"disabled": false,
"created_at": "2026-09-26T01:05:57.670992Z",
"last_login_at": null,
"user_disabled": false
}{"type":"about:blank","title":"Conflict","status":409,"detail":"cannot remove or demote the last owner of a customer","instance":"/members/alice@example.com","request_id":"…"}
Remove a member
DELETE /members/{email}
Console session (browser) · scope members:manage
Removes the membership and revokes the person's sessions and tokens for your account.
Path parameters
emailstring requiredThe member's email.
curl -X DELETE "https://fw.unitcircle.ca/members/bob@contractor.example" \ -b cookies.txt \ -H "X-CSRF-Token: YOUR_CSRF_TOKEN"
http --session=./console.json DELETE "https://fw.unitcircle.ca/members/bob@contractor.example" \ X-CSRF-Token:YOUR_CSRF_TOKEN
import requests
# Log in (POST /auth/login emails you a code): see /docs/authentication#log-in-from-a-script
session = requests.Session()
session.post("https://fw.unitcircle.ca/auth/verify", json={"email": "you@example.com", "code": "123456"}).raise_for_status()
csrf = session.get("https://fw.unitcircle.ca/auth/me").json()["csrf_token"]
r = session.delete(
"https://fw.unitcircle.ca/members/bob@contractor.example",
headers={"X-CSRF-Token": csrf},
)
print(r.status_code, r.text)
(no body)
{"type":"about:blank","title":"Not Found","status":404,"detail":"member not found","instance":"/members/nobody@example.com","request_id":"…"}