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" }
  ]
}
FieldTypeDescription
clientsarrayUp to 500 active clients, ordered by client_name ascending
clients[].iduuid stringClient primary key
clients[].namestringcompany_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)

FieldTypeRequiredConstraints
titlestringYesTrimmed length 1–200
client_idstring (uuid)YesMust belong to the token’s org
embed_urlstringConditionalMust be https://…, max 2000 chars
image_base64stringConditionalRaw 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"
}
FieldTypeDescription
iduuid stringNew deliverable id
share_urlstringClient review URL (Origin header if https, else APP_URL)

Error responses

StatusWhen
400Validation (title, client_id, missing content, bad URL/image)
401Invalid/missing/revoked token
403Org lacks api_access
405Non-POST
422Plan deliverable limit (code: upgrade_required, resource: deliverables) or other insert failure
429Rate limit
502 / 500Storage 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).

FieldTypeNotes
contentstringRequired, 1–20000 chars
client_iduuidValidated against the token's workspace
project_iduuid
deliverable_iduuid
author_kindenumclient (default), member, system, unknown
author_name, author_addressstring
external_idstringDefaults to the Idempotency-Key
metadataobjectMerged; 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.

MethodPathScope
GET/api-v1/time-entriestime:read
GET/api-v1/time-entries/{id}time:read
POST/api-v1/time-entriestime:write
GET/api-v1/invoicesinvoice:read
GET/api-v1/invoices/{id}invoice:read
POST/api-v1/invoicesinvoice:write
GET/api-v1/projectsproject:read
POST/api-v1/projectsproject:write
GET/api-v1/clientsclient:read
POST/api-v1/clientsclient:write
GET/api-v1/deliverablesdeliverable:read
GET/api-v1/deliverables/{id}deliverable:read
POST/api-v1/deliverablesdeliverable:write
POST/api-v1/deliverables/{id}/versionsdeliverable:write
POST/api-v1/deliverables/{id}/senddeliverable: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.

FieldUse it for
review_stateBranch on this. draft / in_review / changes_requested / approved, derived server-side.
current_stateRaw system anchor. Holds only open or signed_off — a CHECK constraint refuses a third value — so it can express "approved" and nothing else.
status_labelDisplay 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:

  1. 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.

  1. 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:

StatuscodeMeaning
409deliverable_signed_offSigned off, which is terminal for every field on the row, not just the state.
422revision_over_budgetThe 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:

ParameterNotes
limit1–200, default 50
cursorOpaque value from the previous response's next_cursor
updated_sinceISO 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:

ScopeGrants
client:readRead clients
project:readRead projects and milestones
time:readRead time entries
invoice:readRead invoices and line items
deliverable:readRead deliverables and their review status
time:writeCreate time entries
invoice:writeCreate draft invoices
deliverable:writeCreate deliverables and add versions
request:writeIngest scope requests
deliverable:sendEmail a client a review link — sensitive, never pre-selected
invoice:issueIssue 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