Endpoints
Every request and response shape, with worked examples.
Base URL: https://<PROJECT_REF>.supabase.co/functions/v1
All endpoints require authentication and api_access.
Common headers:
Authorization: Bearer stria_<token>
apikey: <SUPABASE_ANON_KEY>
Content-Type: application/json # POST bodies only
---
GET /api-list-clients
Returns active clients in the token’s organization for pickers and automations.
Authorization
Bearer API token with api_access.
Request
No body. Query parameters are not supported in v1.
Success response — 200 OK
{
"clients": [
{ "id": "11111111-1111-1111-1111-111111111111", "name": "Acme Corp" },
{ "id": "22222222-2222-2222-2222-222222222222", "name": "Jane Doe" }
]
}
| Field | Type | Description |
|---|---|---|
clients | array | Up to 500 active clients, ordered by client_name ascending |
clients[].id | uuid string | Client primary key |
clients[].name | string | company_name if non-empty, otherwise client_name |
Not returned: email, notes, status, internal metadata.
Cache: Cache-Control: private, no-store
Error responses
See errors.md. Typical: 401, 403, 405, 429, 500.
Examples
cURL
curl -sS \
-H "Authorization: Bearer $STRIA_API_TOKEN" \
-H "apikey: $SUPABASE_ANON_KEY" \
"$STRIA_FUNCTIONS_URL/api-list-clients"
JavaScript (fetch)
const res = await fetch(`${functionsUrl}/api-list-clients`, {
headers: {
Authorization: `Bearer ${token}`,
apikey: anonKey,
},
});
if (!res.ok) throw new Error(await res.text());
const { clients } = await res.json();
---
POST /api-create-deliverable
Creates a deliverable for a client in the token’s organization and returns a share URL.
Authorization
Bearer API token with api_access. Subject to the org’s deliverable plan cap.
Request body (JSON)
| Field | Type | Required | Constraints |
|---|---|---|---|
title | string | Yes | Trimmed length 1–200 |
client_id | string (uuid) | Yes | Must belong to the token’s org |
embed_url | string | Conditional | Must be https://…, max 2000 chars |
image_base64 | string | Conditional | Raw base64 or data: URL; validated image payload |
Content rule: provide at least one of embed_url or image_base64.
When only an image is provided, the server stores a placeholder embed_url (the deliverable’s own share URL) and sets asset_path after uploading to the private deliverable-assets bucket.
Success response — 201 Created
{
"id": "33333333-3333-3333-3333-333333333333",
"share_url": "https://app.example.com/share/33333333-3333-3333-3333-333333333333"
}
| Field | Type | Description |
|---|---|---|
id | uuid string | New deliverable id |
share_url | string | Client review URL (Origin header if https, else APP_URL) |
Error responses
| Status | When |
|---|---|
400 | Validation (title, client_id, missing content, bad URL/image) |
401 | Invalid/missing/revoked token |
403 | Org lacks api_access |
405 | Non-POST |
422 | Plan deliverable limit (code: upgrade_required, resource: deliverables) or other insert failure |
429 | Rate limit |
502 / 500 | Storage attach failures (row rolled back when possible) |
Examples
Embed URL
curl -sS -X POST \
-H "Authorization: Bearer $STRIA_API_TOKEN" \
-H "apikey: $SUPABASE_ANON_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Homepage v2",
"client_id": "11111111-1111-1111-1111-111111111111",
"embed_url": "https://www.figma.com/embed?embed_host=share&url=..."
}' \
"$STRIA_FUNCTIONS_URL/api-create-deliverable"
Inline image (Figma plugin path)
curl -sS -X POST \
-H "Authorization: Bearer $STRIA_API_TOKEN" \
-H "apikey: $SUPABASE_ANON_KEY" \
-H "Content-Type: application/json" \
-d "{
\"title\": \"Frame export\",
\"client_id\": \"11111111-1111-1111-1111-111111111111\",
\"image_base64\": \"data:image/png;base64,iVBORw0KGgo...\"
}" \
"$STRIA_FUNCTIONS_URL/api-create-deliverable"
JavaScript
const res = await fetch(`${functionsUrl}/api-create-deliverable`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
apikey: anonKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({
title: 'Homepage v2',
client_id: clientId,
embed_url: 'https://example.com/preview',
}),
});
const body = await res.json();
if (!res.ok) {
if (body.code === 'upgrade_required' && body.resource === 'deliverables') {
// Route user to billing
}
throw new Error(body.error ?? res.statusText);
}
const { id, share_url } = body;
---
CORS
Class A token-authenticated endpoints (api-list-clients, api-create-deliverable,
api-ingest-request, api-v1) reflect the request Origin — including the literal
null sent by a Figma plugin iframe — because they authenticate with a bearer
stria_ token, not cookies. Class B browser-session endpoints keep the
usestria.com allowlist and do not reflect untrusted origins. Prefer server-side
or automation callers for production secrets.
---
POST /api-ingest-request
Ingests a client message as scope-request evidence.
This endpoint shipped before it was documented. That is recorded as a finding rather
than quietly fixed — a live, token-authenticated endpoint absent from the published
inventory is OWASP API9:2023, because it is never reviewed, gets no contract test, and
cannot be deprecated on purpose. npm run api:check now fails if any api-* function
is missing from openapi.yaml.
Authorization
Bearer API token with the request:write scope, plus the connector_api_ingest
workspace feature.
Request
Idempotency-Key header is required (max 200 chars).
| Field | Type | Notes |
|---|---|---|
content | string | Required, 1–20000 chars |
client_id | uuid | Validated against the token's workspace |
project_id | uuid | |
deliverable_id | uuid | |
author_kind | enum | client (default), member, system, unknown |
author_name, author_address | string | |
external_id | string | Defaults to the Idempotency-Key |
metadata | object | Merged; api_token_id is added server-side |
Responses
201 on ingest, 200 on an idempotent replay, 403 insufficient_scope or
feature_disabled.
---
v1 resource API
Base path: /api-v1. Full schemas in openapi.yaml; contract and
deprecation rules in compatibility.md.
| Method | Path | Scope |
|---|---|---|
| GET | /api-v1/time-entries | time:read |
| GET | /api-v1/time-entries/{id} | time:read |
| POST | /api-v1/time-entries | time:write |
| GET | /api-v1/invoices | invoice:read |
| GET | /api-v1/invoices/{id} | invoice:read |
| POST | /api-v1/invoices | invoice:write |
| GET | /api-v1/projects | project:read |
| POST | /api-v1/projects | project:write |
| GET | /api-v1/clients | client:read |
| POST | /api-v1/clients | client:write |
| GET | /api-v1/deliverables | deliverable:read |
| GET | /api-v1/deliverables/{id} | deliverable:read |
| POST | /api-v1/deliverables | deliverable:write |
| POST | /api-v1/deliverables/{id}/versions | deliverable:write |
| POST | /api-v1/deliverables/{id}/send | deliverable:send |
Deliverables
The scope-to-approval loop, and the surface the Figma plugin is built on: create a
deliverable, revise it as a version, send it for approval, read back where it got to.
Which status field to read
Three fields describe state and only one of them is a contract.
| Field | Use it for |
|---|---|
review_state | Branch on this. draft / in_review / changes_requested / approved, derived server-side. |
current_state | Raw system anchor. Holds only open or signed_off — a CHECK constraint refuses a third value — so it can express "approved" and nothing else. |
status_label | Display only. The freelancer's own label, per-creator and freely renamable, so matching on the text is not a contract. |
POST /api-v1/deliverables versus POST /api-create-deliverable
The legacy endpoint keeps working and is unchanged. The v1 route differs in two ways
that matter to an integration:
- It accepts
project_id, which the legacy route never did even though the column
has existed since 20260624037000. The project must belong to the same client, so a
deliverable cannot be cross-filed onto another client's bill.
- The asset is recorded in version 1. The legacy route inserts and then patches
asset_path, but the v1 snapshot is written by an AFTER INSERT trigger and version
rows are immutable — so its v1 carries a null asset for every image deliverable.
Assets
Inline image_base64, raw base64 or a data URL, 10 MB decoded maximum. The content
type is sniffed from the bytes and only PNG, JPEG, WebP and GIF are accepted; a
declared MIME type or filename is never trusted for what gets stored or where.
SVG and PDF cannot be stored as assets. Send them as an embed_url instead.
Versions are the revision loop
POST /api-v1/deliverables/{id}/versions appends an immutable, hash-chained snapshot
and points the working copy at it. Feedback and sign-offs bind to a version number, so
a new version starts a clean review rather than duplicating the deliverable.
Omitted fields mean unchanged, not cleared — including image_base64, which
carries the current asset forward. A notes-only version does not delete the image the
client is looking at.
A new version does not count against the plan's deliverable cap. Only a new deliverable
does.
Two refusals are specific to this route:
| Status | code | Meaning |
|---|---|---|
409 | deliverable_signed_off | Signed off, which is terminal for every field on the row, not just the state. |
422 | revision_over_budget | The project's included revision rounds are used up and no override is recorded. Only fires on a deliverable attached to a project with an allowance. |
Sending is a separate authorisation
POST /api-v1/deliverables/{id}/send requires deliverable:send, which is marked
sensitive and is never pre-selected at mint. It emails the client the share link, stamps
sent_at, clears any pending reminder, and advances the deliverable to whichever status
the workspace has flagged "on send".
The Idempotency-Key here exists to stop a retry emailing a client twice. The key is
claimed before anything is sent; a replay returns 200 with
idempotent_replay: true and sends nothing. If the send genuinely fails the claim is
released, so a 502 email_send_failed may be retried with the same key. The
fingerprint includes the version number, so sending v3 after v2 went out is new work.
ok: true means the client was emailed. It does not promise the status advanced — read
status_warning, which is set when the email went out but the status could not be
changed (most often because no status is flagged "on send"). Once a client has been
emailed, reporting a failure would invite a duplicate send, so this is a warning on a
success rather than an error.
Pagination
Keyset (cursor), not offset. Every list endpoint accepts:
| Parameter | Notes |
|---|---|
limit | 1–200, default 50 |
cursor | Opaque value from the previous response's next_cursor |
updated_since | ISO 8601. Returns only records changed at or after it |
{ "data": [ … ], "next_cursor": "eyJ1cGRhdGVkQXQiOiI…" }
next_cursor is null on the last page. The cursor is opaque on purpose — parsing it
would make you dependent on the sort key, and then changing the sort becomes a
breaking change for you.
updated_since is a real modification delta on every list endpoint, including
/api-v1/projects and /api-v1/clients. A project moving active → completed, or a
client being renamed or reassigned, appears in it.
Those two routes previously sorted on creation time, so an edit was invisible to a
poller — a 200 and an empty page, with nothing to indicate the caller was now stale.
Callers that never relied on updated_since see identical results; callers that did now
see the changes too. Rows that predate the fix and have never been edited report
updated_at equal to created_at, so the first sweep after upgrading is not a full
resync.
Writes
Idempotency-Key is required on every write.
For time entries the key is mapped onto time_entries.client_ref, which already
carries a partial unique index for the offline-mobile replay path — so a retry after a
timeout returns the original entry with idempotent_replay: true and 200, rather
than creating a duplicate. A genuine create returns 201.
Writes are attributed to the admin who minted the token, and that admin's status is
re-checked on every call. If they are no longer an admin of the workspace you get
409 api_actor_unavailable — a credential must not outlive the authority it was
granted under. Mint a new token.
What the API deliberately will not do
POST /api-v1/invoices creates a draft. It never issues or sends.
Issuing emails a client and starts a money flow, which OWASP API6:2023 classes as a
sensitive business flow — it must not ride along on a scope someone granted for
bookkeeping. A separate invoice:issue scope is reserved in the catalogue for that
capability. No endpoint honours it, so it is not grantable: requesting it returns
Scope not available yet, distinct from the error for an unknown scope. It stays
published so that when issuing does ship you will not have to re-mint existing tokens —
and stays ungrantable so no token accrues the permission before the route exists.
Nothing in the API mutates payment state. Payment state for a Stripe invoice belongs
to Stripe, and the webhook handler is its only writer in Stria.
Reconciliation
updated_since plus invoice.paid is the intended pairing: the
webhook for latency, the poll for correctness. Webhook delivery is at-least-once and
can lag during a consumer outage, so a nightly updated_since sweep is what makes an
integration self-healing without replaying deliveries by hand.
Scopes
Grant the minimum. Available scopes:
| Scope | Grants |
|---|---|
client:read | Read clients |
project:read | Read projects and milestones |
time:read | Read time entries |
invoice:read | Read invoices and line items |
deliverable:read | Read deliverables and their review status |
time:write | Create time entries |
invoice:write | Create draft invoices |
deliverable:write | Create deliverables and add versions |
request:write | Ingest scope requests |
deliverable:send | Email a client a review link — sensitive, never pre-selected |
invoice:issue | Issue invoices to clients — reserved, not yet grantable (see above) |
More reference
- Authentication — How tokens are minted, stored, presented and revoked.
- Outbound webhooks — Signed HTTPS POSTs when work is signed off, a change order moves, or an invoice is paid — plus how to verify one and how to survive a duplicate.
- Rate limits — Per organization, per minute, and published rather than discovered as a random failure.
- Errors — What each status means, what the body carries, and which ones to retry.
- Compatibility and versioning — What may change without notice, what may not, and how you are told.
- API changelog — Every change, dated, including the ones that were corrections.
OpenAPI specification · API access is on every paid plan · Start your 14-day trial