Apps and OAuth
An app is how a frontend you host yourself talks to undrstand on a user's behalf. You register it once, it asks users for the scopes it needs, and each user decides on a consent screen. This page covers registration, review, the scopes, and the authorization code flow with PKCE.
Why apps
Your own token is yours: it opens everything on your account. A frontend built for other people must not hold their tokens. It holds a token each user granted it, limited to what they approved.
That is what an app is: a registered client with a name, redirect URIs and a list of scopes it may ask for. A user who signs in to your app sees a consent screen with those scopes and decides.
This page is for an app you write and host yourself. An app made with the builder in undrstand is hosted for you and needs none of this. Build your app
Register an app
Register with your own account and your own token. An app cannot register apps for a user: the apps routes refuse tokens that were granted to an app.
curl -X POST https://api.undrstand.com/apps \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -H "Accept: application/json" \
-d '{
"name": "Pattern board",
"redirect_uris": ["https://board.example.com/callback"],
"scopes": ["documents:read", "patterns:read", "patterns:review"],
"website_url": "https://board.example.com",
"privacy_policy_url": "https://board.example.com/privacy",
"confidential": false
}'Rules for the request: a name of at most a hundred characters; one to ten redirect URIs, https only, except http on localhost or 127.0.0.1 on any port while developing, and never a fragment or a custom scheme; at least one of the eight scopes, and never mcp:use, which only Claude and ChatGPT get; an optional https website URL and privacy policy URL, both shown on the consent screen. The website and the privacy policy are needed before review, and so is every redirect URI being on the website's domain.
{
"data": {
"id": "0199a3c2-…",
"name": "Pattern board",
"redirect_uris": ["https://board.example.com/callback"],
"website_url": "https://board.example.com",
"privacy_policy_url": "https://board.example.com/privacy",
"domain": {
"name": "board.example.com",
"record": {"type": "TXT", "host": "board.example.com", "value": "undrstand-verification=Xq7…"},
"verified": false,
"checked_at": null
},
"scopes": ["documents:read", "patterns:read", "patterns:review"],
"approved_scopes": [],
"pending_changes": {},
"review": {"status": "none", "listing": null, "note": null, "sent_in_at": null, "reviewed_at": null},
"confidential": false,
"secret": null,
"created_at": "2026-09-27T09:12:44.000000Z",
"updated_at": "2026-09-27T09:12:44.000000Z"
}
}The response is your app. Keep the id: it is the client_id in the flow. Change the name, redirect URIs, website, privacy policy or scopes later with a patch on the app, list your apps, or delete one, which revokes every token it was granted.
GET https://api.undrstand.com/apps # your apps, paginated (page, limit)
GET https://api.undrstand.com/apps/{id}
PATCH https://api.undrstand.com/apps/{id} # name, redirect_uris, website_url, privacy_policy_url, scopes
DELETE https://api.undrstand.com/apps/{id} # 204, revokes every token the app was grantedPublic or confidential
An app is public by default: a browser page, a mobile app, anything that cannot keep a secret. It proves itself with PKCE alone.
Pass confidential true for an app with a server that can keep a secret. The secret is in the create response once and nowhere after; store it then. A confidential app sends it as client_secret at the token endpoint, next to PKCE.
Prove your domain
Before anyone else may connect your app, undrstand checks that the domain of its website is yours, as Google and Facebook do. The app shows the record to add under domain: a TXT record on the domain itself, without www, holding a value that is your app's own.
Add it at your DNS provider, then ask undrstand to look for it. Found, domain.verified turns true; not yet, and you get a 422 that says so. DNS changes can take a while to show, so try again later if it is not there yet.
board.example.com. TXT "undrstand-verification=Xq7…"
curl -X POST https://api.undrstand.com/apps/0199a3c2-…/domain \
-H "Authorization: Bearer $TOKEN" -H "Accept: application/json"
# {"data": {…, "domain": {"name": "board.example.com", …, "verified": true, …}}}Your redirect URIs and privacy policy have to be on that domain or one of its subdomains before you send the app in. Keep localhost for a second app that you never send in. Leave the record in place: undrstand looks for it every week, and once it is gone nobody but you can connect the app until you check the domain again. You get a notification when that happens.
Review
A new app works for one account: yours. You can run the whole flow against your own data straight away. Anyone else is refused with access_denied until undrstand has reviewed the app.
Send it in when it is ready, with what its listing will say: who made it and what it does. The review field on the app tells you where it stands: status goes from none to pending, then approved or rejected, and a rejection comes with a note that says why. You can take a request back while it waits.
curl -X POST https://api.undrstand.com/apps/0199a3c2-…/review \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -H "Accept: application/json" \
-d '{
"developer": "Board Labs",
"description": "A board of your patterns: what keeps coming back, with the days it showed up on."
}'
# {"data": {…, "approved_scopes": [],
# "review": {"status": "pending", "listing": {"developer": "Board Labs", "description": "…"}, …}}}
DELETE https://api.undrstand.com/apps/{id}/review # take the request back while it waitsOnce approved, approved_scopes holds what other people may grant it, and the app is listed under Apps in the web app, with the apps that connect to undrstand. A token for someone else never carries more than the approved scopes.
Changes after approval are reviewed too. Scopes you add reach other people only after you send the app in again. A new name, redirect URIs, website or privacy policy wait in pending_changes and take effect when approved. Changing what is under review while the app waits takes the request back, so what is approved is what was looked at.
The flow
Authorization code with PKCE, on the API domain. There is no other grant.
Make a code verifier and its challenge. The verifier is a random string of 43 to 128 characters; the challenge is its SHA-256 hash, base64url encoded.
PKCE// browser or Node 20+ const bytes = crypto.getRandomValues(new Uint8Array(32)); const base64url = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))) .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); const verifier = base64url(bytes); // keep it const challenge = base64url( await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)), );Send the user to the authorize endpoint. Scopes are space separated; state is yours to check on the way back.
GET /oauth/authorizehttps://api.undrstand.com/oauth/authorize ?response_type=code &client_id=0199a3c2-… &redirect_uri=https://board.example.com/callback &scope=documents:read patterns:read &state=k3j9… &code_challenge=… &code_challenge_method=S256A user who is not signed in lands on the connect page first, signs in with a mailed link or a social account, and comes back. Then the consent screen shows your verified domain, your privacy policy and where it sends the user back to, and lists the scopes you asked for, limited to the ones your app registered with. On approve, the user returns to your redirect URI with a code and your state.
https://board.example.com/callback?code=def502…&state=k3j9…Exchange the code for tokens. Send the verifier from step 1. A confidential app adds its client_secret.
POST /oauth/tokencurl -X POST https://api.undrstand.com/oauth/token \ -H "Content-Type: application/json" -H "Accept: application/json" \ -d '{ "grant_type": "authorization_code", "client_id": "0199a3c2-…", "redirect_uri": "https://board.example.com/callback", "code": "def502…", "code_verifier": "…" }' # {"token_type": "Bearer", "expires_in": 86400, # "access_token": "eyJ…", "refresh_token": "def502…"}Call the API with the access token as a bearer header, exactly like a personal token.
GET /patternscurl https://api.undrstand.com/patterns \ -H "Authorization: Bearer eyJ…" -H "Accept: application/json"
Scopes
A route refuses a token without its scope with a 403 that names the scope. The profile route needs a valid token and no scope, so an app can always tell who it is talking to.
HTTP/1.1 403 Forbidden
{"message": "This token is missing the documents:write scope."}| Scope | Opens | The user reads |
|---|---|---|
documents:read | list, read and search documents | Read what you handed in and what was found in it |
documents:write | post and delete documents | Hand in new text on your behalf and delete documents |
patterns:read | patterns, one pattern with its correlations and flows, the timeline | Read your patterns, correlations, flows and timeline |
patterns:review | accept or reject patterns and correlations | Accept or reject patterns and correlations for you |
observations:read | what the Observer told the user | Read what your observer noticed and told you |
health:read | what connected wearables measured | Read what your connected wearables measured, such as sleep and heart rate |
settings:read | read settings | Read your settings |
settings:write | change settings | Change your settings |
Refresh and expiry
An access token lives one day, a refresh token thirty. Refresh before the day is over; the answer is a new pair and the old refresh token is gone.
curl -X POST https://api.undrstand.com/oauth/token \
-H "Content-Type: application/json" -H "Accept: application/json" \
-d '{
"grant_type": "refresh_token",
"client_id": "0199a3c2-…",
"refresh_token": "def502…"
}'A user can pull the plug: deleting an app revokes its tokens, and so does deleting the account. Expect a 401 at any time and send the user through the flow again.
Rules for apps
- Ask only for the scopes you use. A pattern board needs patterns:read, not documents:write.
- Register exact redirect URIs on your verified domain. The flow refuses one that is not on the list.
- This is private life data. Show it, do not keep it: store nothing beyond what the user asked you to, and never move it to another service without saying so.
- Do not present a pattern as a diagnosis or a label. Describe what the data shows, in the words the description already uses.
- Never name the analysis method. The user gets the insight, not the machinery.
- Your app reads what the user's plan reads. Show a locked pattern as locked; do not work around it.