Skip to content

App HTTP requests

API

Your App’s code talks to work.studio through the App SDK. This page lists the requests the SDK makes, so that when a call fails you can see which request it was, check it with curl, and tell a wrong address from a missing collection or a lapsed sign-in.

What Address In .env.local
The API https://api.work.studio. Every path below starts with /api/v1/workflow/ VITE_WORKSTUDIO_API
The hosted sign-in https://auth.work.studio/realms/apps VITE_WORKSTUDIO_ISSUER
The sign-in client of the build The client id the platform made for the App’s web, iPhone or Android build VITE_WORKSTUDIO_CLIENT_ID
App functions /api/* on the App’s own address, such as https://<address>.workstudio.run/api/pro-tip –

npx workstudio apps apply writes .env.local, and the build bakes the values in. If data calls go to another address, .env.local is out of date or was written by hand: run npx workstudio apps apply again, then build again.

Signing in takes three requests. The SDK makes all of them; your code calls app.auth.signIn() and, when the person comes back, app.auth.completeSignIn().

  1. The hosted sign-in page. The browser goes to {issuer}/protocol/openid-connect/auth with response_type=code, the client_id, a redirect_uri (the page’s own origin unless you set one), scope=openid profile email, and a PKCE code_challenge with code_challenge_method=S256.

  2. The code for tokens. POST {issuer}/protocol/openid-connect/token, as a form: grant_type=authorization_code, the code, the redirect_uri, the client_id and the code_verifier.

  3. The swap for a work.studio token. POST https://api.work.studio/api/v1/workflow/connection/oauth/token, as a form:

    grant_type=urn:ietf:params:oauth:grant-type:token-exchange
    subject_token=<the access token from step 2>
    subject_token_type=urn:ietf:params:oauth:token-type:access_token

    The answer is { "access_token": "…", "issued_token_type": "…", "token_type": "Bearer", "expires_in": … }. That access_token is the person’s work.studio token. Every other call carries it, and the SDK renews it before it runs out.

The swap is limited per IP address: 300 a minute, and once 20 swaps from an address have been refused within 5 minutes, every swap from it is refused until those 5 minutes are up. Over either limit the answer is 429 with Retry-After.

A refused swap answers in the OAuth error form, { "error": …, "error_description": … }:

Status error code What it means
400 invalid_request – No subject_token was sent
400 invalid_grant – The sign-in is invalid or has expired. Sign in again
403 invalid_target NOT_A_MEMBER The App is members-only and the person’s verified email isn’t a member’s
403 invalid_target – The platform doesn’t let this person into the App, for example because its sign-in was turned off. The SDK signs them out
503 temporarily_unavailable – Try again in a moment

Every call is made as the signed-in person, with these headers:

Authorization: Bearer <the person's work.studio token>
Accept: application/json
Content-Type: application/json (when there is a body)

The token says which App and environment the call is for, and who the person is. There is no App id or environment header to add.

A collection’s records are under /api/v1/workflow/data/collections/{collection}/records. These are the requests behind app.data.collection(name):

SDK call Request Body Answer
find(options) GET …/records?limit=&offset=&sortBy=&ascending=&<field>=<value> – { "records": [ … ], "total": … }
get(id) GET …/records/{id} – One record. 404 when the person has no such record (get returns null)
create(data, { key }) POST …/records { "record": { … }, "key": "…" } (key optional) The saved record
replace(id, data) PUT …/records/{id} { "record": { … } } The saved record
update(id, changes) GET …/records/{id}, then PUT …/records/{id} with the merged fields The saved record
delete(id) DELETE …/records/{id} – { "deleted": true }
app.data.collections() GET /api/v1/workflow/data/collections – The collections the App declares

A record’s JSON can be up to 1 MB. A larger create, replace or update answers 413 with { "code": "RECORD_TOO_LARGE", "limitBytes": 1048576, "bytes": … } and a message saying how big it was.

A record looks like this. In a collection a members-only App shares, it also has createdBy, createdByName, updatedBy and updatedByName.

{
"id": "6f1c2a9e-…",
"key": null,
"data": { "text": "Buy flour" },
"createdAt": "2026-10-27T17:04:12.345Z",
"updatedAt": "2026-10-27T17:04:12.345Z"
}
  1. Open the App in the browser and sign in.
  2. Open the browser’s developer tools, then the Network tab, and filter on /api/v1/workflow/.
  3. Find the failing request. Its status and answer usually say what is wrong (see Troubleshoot App sign-in and data).
  4. To repeat it, copy it as cURL from the request’s menu, or copy the token from its Authorization header and run:
Terminal window
TOKEN='<the token after "Bearer ">'
curl -s -H "Authorization: Bearer $TOKEN" \
"https://api.work.studio/api/v1/workflow/data/collections/notes/records?limit=5"

The token works as the person who signed in, for about 15 minutes. Don’t paste it anywhere you wouldn’t paste their password.

The rest of the SDK follows the same pattern, with the same headers. These are the requests most Apps make:

SDK call Request
app.account.me() GET /api/v1/workflow/connection/end-users/me
app.account.deletionCheck() GET /api/v1/workflow/connection/end-users/me/delete
app.account.delete() POST /api/v1/workflow/connection/end-users/me/delete with { "confirm": "DELETE" }
app.billing.plan() GET /api/v1/workflow/connection/end-users/me/plan
app.billing.checkout() POST /api/v1/workflow/connection/end-users/me/checkout
app.billing.manage() POST /api/v1/workflow/connection/end-users/me/billing-portal
app.chat.start() POST /api/v1/workflow/gateway/ai-sessions. The answer carries a conversation id and its own token
conversation.send() POST /api/v1/workflow/ai-runtime/direct/sessions/{id}/messages/stream, with the conversation’s token as the bearer and Accept: text/event-stream. The reply streams as server-sent events
conversation.stop() POST /api/v1/workflow/ai-runtime/sessions/mine/{id}/stop. The answer is { "result": "stopped" }, "stopping" or "not_running"; the stream then ends with a cancelled event
app.files.upload() POST /api/v1/workflow/storage/my-files (multipart)
app.content.pack(name) GET /api/v1/workflow/catalog/app-content/{name}
app.shares.create() POST /api/v1/workflow/connection/end-users/me/shares

A refused call answers with a JSON body that has a message in message or error, and sometimes a code. The SDK throws it as an AppError with status, message, code and the rest of the body in details:

Status The SDK throws What to do
0 (no answer) AppError, code NETWORK The request never reached work.studio. Check the address and the browser console
401 SignInRequired Call app.auth.signIn()
402 CreditsUsedUp The person is out of credits for now
403 AppError Read message: it says what isn’t allowed
413 AppError, code RECORD_TOO_LARGE The record is over 1 MB. Keep large content in a file
429 AllowanceUsed or AiPaused The AI allowance, or the App’s AI, is used up until resetAt
429 AppError Too many requests from one address. Wait for the seconds in Retry-After, then try again
5xx AppError Try again. A 502 or 500 from a data call carries an errorRef to quote to support

What each message means is on Troubleshoot App sign-in and data.