App HTTP requests
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.
The addresses
Section titled “The addresses”| 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.
Sign-in
Section titled “Sign-in”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().
-
The hosted sign-in page. The browser goes to
{issuer}/protocol/openid-connect/authwithresponse_type=code, theclient_id, aredirect_uri(the page’s own origin unless you set one),scope=openid profile email, and a PKCEcode_challengewithcode_challenge_method=S256. -
The code for tokens.
POST {issuer}/protocol/openid-connect/token, as a form:grant_type=authorization_code, thecode, theredirect_uri, theclient_idand thecode_verifier. -
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-exchangesubject_token=<the access token from step 2>subject_token_type=urn:ietf:params:oauth:token-type:access_tokenThe answer is
{ "access_token": "…", "issued_token_type": "…", "token_type": "Bearer", "expires_in": … }. Thataccess_tokenis 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 other call
Section titled “Every other call”Every call is made as the signed-in person, with these headers:
Authorization: Bearer <the person's work.studio token>Accept: application/jsonContent-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"}Check a data call with curl
Section titled “Check a data call with curl”- Open the App in the browser and sign in.
- Open the browser’s developer tools, then the Network tab, and filter on
/api/v1/workflow/. - Find the failing request. Its status and answer usually say what is wrong (see Troubleshoot App sign-in and data).
- To repeat it, copy it as cURL from the request’s menu, or copy the token from its
Authorizationheader and run:
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.
Other calls
Section titled “Other calls”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 |
Errors
Section titled “Errors”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.