API reference
v1 · live REST surface · grounded in route handlers

Every STRALO endpoint, with the curl that actually works.

Method, path, headers, body shape, status codes, example response, and a paste-ready curl for each one — sourced straight from src/lib/contracts/* and the matching src/app/api/**/route.ts. When a route drifts, edit the handler and /docs together — this page is the public mirror of what the code actually accepts, not a copy someone wrote once and never updated.

Auth, in one paragraph

Every endpoint below except POST /api/agents requires an Authorization: Bearer <sk_…> header. The plaintext key is minted exactly once, in the 201 response of POST /api/agents, and stored on the server only as a SHA-256 hash. Treat it like a database password — rotate from /dashboard if it leaks. There is no global API key, no service-account key, and no human login.

POST/api/agents
201
No auth

Mint an agent seat. Open endpoint — this is the issuance surface; every other endpoint trusts the key it returns. The plaintext API key is returned in the 201 body exactly once; the database stores only its SHA-256 hash.

Required headers

  • None — this endpoint mints the credential that the others require.
  • Content-Type: application/json

Request body

{
  // name          string   optional  1-120 chars
  // contactEmail  string   optional  valid email
}

A POST with an empty body is valid and produces an anonymous agent — exactly
what /quickstart uses to mint a key in one round-trip. Pass {"name": "…"} or
{"contactEmail": "…"} (or both) if you want to attach a label or contact.

The plaintext key is the credential for every later call — save it before you continue.

Error codes

  • 400invalid_requestBody is not valid JSON.
  • 400Bad RequestZod validation failed — body is { errors: { name?, contactEmail? } }.
  • 500Internal Server ErrorHelper insert raised past the contract guard.

Response

{
  "agentId": "ag_5b8e3a1c9c2b4e1c8f7d6a5b",
  "apiKey":   "sk_<redacted — surfaced once, never re-fetchable>"
}

curl

POST/api/agents
curl -X POST https://stralo.polsia.app/api/agents \
  -H "Content-Type: application/json" \
  -d '{}'
POST/api/bookings
201
Bearer required

Confirm a booking on the bearer's calendar. The bearer's agentId MUST match the body's agentId — a token for agent A cannot create bookings on behalf of agent B. Overlap rejections (same agentId, overlapping tstzrange) return 409 slot_taken because the database's bookings_no_overlap EXCLUDE constraint raises 23P01 at commit time.

Required headers

  • Authorization: Bearer <YOUR_API_KEY> — the plaintext sk_<uuid> emitted once by POST /api/agents.
  • Content-Type: application/json

Request body

{
  "agentId":  "<YOUR_AGENT_ID>",   // required, 1-128 chars
  "startsAt": "2026-08-18T15:42:18.196Z",      // required, RFC 3339 with offset
  "endsAt":   "2026-08-18T16:42:18.196Z"         // required, RFC 3339 with offset, endsAt > startsAt
}

Error codes

  • 401unauthorizedMissing or unparseable Authorization header.
  • 400Bad RequestBody failed Zod validation — { errors: { agentId?, startsAt?, endsAt? } }. endsAt must be after startsAt.
  • 403forbiddenBearer resolved, but bearer.agentId != body.agentId.
  • 409slot_takenThe window overlaps an existing confirmed row on this agentId — the route maps Postgres 23P01 to 409.
  • 429booking_cap_reachedFree-tier agent cap exceeded (2 agents).
  • 500Internal Server ErrorUnexpected helper failure past the contract guard.

Response

{
  "id":             "bk_4f1a93de8c7240cda0f3b9e2",
  "agentId":        "<YOUR_AGENT_ID>",
  "startsAt":       "2026-08-18T15:42:18.196Z",
  "endsAt":         "2026-08-18T16:42:18.196Z",
  "status":         "confirmed",
  "rrule":          null,
  "warningSentAt":  null,
  "createdAt":      "2026-08-18T15:40:26.046Z"
}

curl

POST/api/bookings
curl -X POST https://stralo.polsia.app/api/bookings \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -d '{
    "agentId":  "<YOUR_AGENT_ID>",
    "startsAt": "2026-08-18T15:42:18.196Z",
    "endsAt":   "2026-08-18T16:42:18.196Z"
  }'
GET/api/bookings
200
Bearer required

List confirmed AND cancelled rows that belong to the bearer, ordered by startsAt ascending. Optional ?limit (1..200, default 50), ?from, ?to narrow the result to bookings whose [startsAt, endsAt) window intersects [from, to). Half-open [) boundaries — back-to-back bookings do not collide.

Required headers

  • Authorization: Bearer <YOUR_API_KEY> — the plaintext sk_<uuid> emitted once by POST /api/agents.

Error codes

  • 401unauthorizedMissing or unparseable Authorization header.
  • 400Bad RequestQuery failed Zod validation — { errors: { limit?, from?, to? } }. to must be on or after from.
  • 500Internal Server ErrorUnexpected helper failure past the contract guard.

Response

{
  "items": [
    {
      "id":             "bk_4f1a93de8c7240cda0f3b9e2",
      "agentId":        "<YOUR_AGENT_ID>",
      "startsAt":       "2026-08-18T15:42:18.196Z",
      "endsAt":         "2026-08-18T16:42:18.196Z",
      "status":         "confirmed",
      "rrule":          null,
      "warningSentAt":  null,
      "createdAt":      "2026-08-18T15:40:26.046Z"
    }
  ]
}

curl

GET/api/bookings
curl https://stralo.polsia.app/api/bookings \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -G --data-urlencode "limit=50" \
  --data-urlencode "from=2026-08-18T15:42:18.196Z"
DELETE/api/bookings/{id}
204
Bearer required

Soft-cancel a booking — sets status='cancelled' but preserves the audit row (no DELETE on the table). Idempotent: cancelling an already-cancelled id returns 204. The bearer's agentId must own the row; cross-agent cancellation is structurally impossible because the UPDATE is keyed by bearer-derived agentId.

Required headers

  • Authorization: Bearer <YOUR_API_KEY> — the plaintext sk_<uuid> emitted once by POST /api/agents.

Error codes

  • 401unauthorizedMissing or unparseable Authorization header.
  • 403forbiddenRow exists but does not belong to this bearer.
  • 404not_foundNo row matches {id} — already-deleted, never existed, or wrong id.
  • 500Internal Server ErrorUnexpected helper failure past the contract guard.

Response

(204 — no body)

The booking row remains in the table with status='cancelled'; the
bookings_no_overlap constraint is partial on status='confirmed', so the
freed window is immediately available to a fresh POST.

curl

DELETE/api/bookings/{id}
curl -X DELETE https://stralo.polsia.app/api/bookings/bk_4f1a93de8c7240cda0f3b9e2 \
  -H "Authorization: Bearer <YOUR_API_KEY>"
GET/api/bookings/{id}/occurrences
200
Bearer required

Expand a booking's RRULE into the next N concrete tstzrange slots starting from now. Default limit 50, max 200. Slots are returned in chronological order (startsAt ascending). The id must belong to the bearer.

Required headers

  • Authorization: Bearer <YOUR_API_KEY> — the plaintext sk_<uuid> emitted once by POST /api/agents.

Error codes

  • 401unauthorizedMissing or unparseable Authorization header.
  • 403forbiddenRow exists but does not belong to this bearer.
  • 404not_foundNo row matches {id}.
  • 400bad_rruleThe stored RRULE did not parse — body { error: "bad_rrule", message }. The row stays; cancel + re-create it.
  • 500Internal Server ErrorUnexpected helper failure past the contract guard.

Response

{
  "items": [
    {
      "startsAt": "2026-08-25T14:42:18.196Z",
      "endsAt":   "2026-08-25T15:42:18.196Z"
    },
    {
      "startsAt": "2026-09-01T14:42:18.196Z",
      "endsAt":   "2026-09-01T15:42:18.196Z"
    }
  ]
}

curl

GET/api/bookings/{id}/occurrences
curl https://stralo.polsia.app/api/bookings/bk_4f1a93de8c7240cda0f3b9e2/occurrences \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -G --data-urlencode "limit=5"
POST/api/proposals
201
Bearer required

Open a slot-transfer proposal — ask another agent's calendar for a window. proposerAgentId is set server-side from the bearer, NEVER trusted from the body. The proposal starts in status='pending'; the target agent must accept (PATCH on /accept) or reject (PATCH on /reject) before any Booking row exists.

Required headers

  • Authorization: Bearer <YOUR_API_KEY> — the plaintext sk_<uuid> emitted once by POST /api/agents.
  • Content-Type: application/json

Request body

{
  "targetAgentId": ag_2d7c44f1800f4bc9b370f93e,  // required, 1-128 chars
  "startsAt":      2026-08-18T17:42:18.196Z, // required, RFC 3339 with offset
  "endsAt":        2026-08-18T18:42:18.196Z    // required, RFC 3339 with offset, endsAt > startsAt
}

// proposerAgentId is set server-side from the bearer — never trusted from the
// body, never present on the wire. A token for agent A cannot propose on
// behalf of agent B.

Error codes

  • 401unauthorizedMissing or unparseable Authorization header.
  • 400Bad RequestBody failed Zod validation — { errors: { targetAgentId?, startsAt?, endsAt? } }. endsAt must be after startsAt.
  • 500Internal Server ErrorUnexpected helper failure past the contract guard.

Response

{
  "id":               "pr_3a82c1e0bdfe4d39b58d2ac9",
  "proposerAgentId":  "<YOUR_AGENT_ID>",
  "targetAgentId":    "ag_2d7c44f1800f4bc9b370f93e",
  "startsAt":         "2026-08-18T17:42:18.196Z",
  "endsAt":           "2026-08-18T18:42:18.196Z",
  "status":           "pending",
  "createdAt":        "2026-08-18T15:40:26.046Z"
}

curl

POST/api/proposals
curl -X POST https://stralo.polsia.app/api/proposals \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -d '{
    "targetAgentId": "ag_2d7c44f1800f4bc9b370f93e",
    "startsAt":      "2026-08-18T17:42:18.196Z",
    "endsAt":        "2026-08-18T18:42:18.196Z"
  }'
PATCH/api/proposals/{id}/accept
200
Bearer required

The TARGET agent accepts the proposal. PATCH transfers ownership of the resulting Booking to the proposer (Booking.agentId = proposal.proposerAgentId), so the slot flips to the proposer's calendar; a booking.created webhook fires against the new owner. POST on this path also exists but keeps the slot where it was (Booking.agentId = proposal.targetAgentId) — only PATCH performs the transfer.

Required headers

  • Authorization: Bearer <YOUR_API_KEY> — the plaintext sk_<uuid> emitted once by POST /api/agents.
  • Content-Type: application/json

Error codes

  • 401unauthorizedMissing or unparseable Authorization header.
  • 403forbiddenProposal exists but the bearer is not the target agent.
  • 404not_foundNo proposal matches {id}.
  • 409proposal_not_pendingProposal is already accepted or declined.
  • 409slot_takenThe transfer window overlaps an existing confirmed Booking on the PROPOSER calendar — the EXCLUDE constraint raised 23P01; the proposal flip was rolled back.
  • 500Internal Server ErrorUnexpected helper failure past the contract guard.

Response

{
  "id":               "pr_3a82c1e0bdfe4d39b58d2ac9",
  "proposerAgentId":  "<YOUR_AGENT_ID>",
  "targetAgentId":    "ag_2d7c44f1800f4bc9b370f93e",
  "startsAt":         "2026-08-18T17:42:18.196Z",
  "endsAt":           "2026-08-18T18:42:18.196Z",
  "status":           "accepted",
  "createdAt":        "2026-08-18T15:42:18.196Z"
}

curl

PATCH/api/proposals/{id}/accept
curl -X PATCH https://stralo.polsia.app/api/proposals/pr_3a82c1e0bdfe4d39b58d2ac9/accept \
  -H "Authorization: Bearer <YOUR_API_KEY>"
PATCH/api/proposals/{id}/reject
200
Bearer required

The TARGET agent rejects the proposal. Resets status to 'pending' so a future transfer attempt is possible (not a terminal decline) — only the original slot owner can reject, and the UPDATE is conditional on status='pending' so a concurrent accept yields 409 proposal_not_pending, not a double-flip.

Required headers

  • Authorization: Bearer <YOUR_API_KEY> — the plaintext sk_<uuid> emitted once by POST /api/agents.
  • Content-Type: application/json

Error codes

  • 401unauthorizedMissing or unparseable Authorization header.
  • 403forbiddenProposal exists but the bearer is not the target agent.
  • 404not_foundNo proposal matches {id}.
  • 409proposal_not_pendingProposal is already accepted or raced past pending.
  • 500Internal Server ErrorUnexpected helper failure past the contract guard.

Response

{
  "id":               "pr_3a82c1e0bdfe4d39b58d2ac9",
  "proposerAgentId":  "<YOUR_AGENT_ID>",
  "targetAgentId":    "ag_2d7c44f1800f4bc9b370f93e",
  "startsAt":         "2026-08-18T17:42:18.196Z",
  "endsAt":           "2026-08-18T18:42:18.196Z",
  "status":           "pending",
  "createdAt":        "2026-08-18T15:42:18.196Z"
}

curl

PATCH/api/proposals/{id}/reject
curl -X PATCH https://stralo.polsia.app/api/proposals/pr_3a82c1e0bdfe4d39b58d2ac9/reject \
  -H "Authorization: Bearer <YOUR_API_KEY>"
POST/api/stripe-billing/checkout
200
No auth

Start a Stripe Checkout session. The browser posts a productId from the catalog and the server prices it server-side (the price is NEVER trusted from the request body), then returns the hosted-checkout redirect URL. There is NO inbound Stripe webhook — see the Payments model note below this list.

Required headers

  • None — this endpoint mints the credential that the others require.
  • Content-Type: application/json

Request body

{
  "productId": "example",        // required, must match a CATALOG key
  "quantity":  1                 // optional, 1-99, one-time charges only
}

Error codes

  • 400invalid_requestBody failed validation. productId is required.
  • 404unknown_productproductId is not in CATALOG — server-side, not the browser.
  • 503payments_not_enabledPolsia payments aren't enabled for this app yet.
  • 503stripe_billing_not_configuredStripe isn't fully configured server-side. Email stralo@polsia.app for help.
  • 502checkout_failedCheckout session could not be created — retry.

Response

{
  "url": "https://checkout.stripe.com/c/pay/cs_test_…#:~:text=…"
}

curl

POST/api/stripe-billing/checkout
curl -X POST https://stralo.polsia.app/api/stripe-billing/checkout \
  -H "Content-Type: application/json" \
  -H "Origin: https://stralo.polsia.app" \
  -d '{ "productId": "example" }'
GET/api/stripe-billing/verify
200
No auth

Verify a completed checkout on the success page. The browser extracts session_id from the success URL (Stripe substitutes {CHECKOUT_SESSION_ID}) and posts it here; the server looks up the session through Polsia's payment-events feed. There is NO inbound Stripe webhook — fulfillment is verify-on-success plus an optional cursor poll against listPaymentEvents / processNewPaymentEvents.

Required headers

  • None — this endpoint mints the credential that the others require.

Error codes

  • 400Bad Requestsession_id is missing or malformed.
  • 503stripe_billing_not_configuredStripe server config missing — cannot verify yet.
  • 502payment_verification_failedVerification upstream returned a non-OK or timed out — retry, then email if it persists.

Response

{
  "verified":   true,
  "sessionId": "cs_test_a1b2c3d4e5f6g7h8",
  "productId": "example",
  "amountUsd":  19,
  "currency":  "usd",
  "paidAt":    "2026-08-18T15:40:26.046Z"
}

curl

GET/api/stripe-billing/verify
curl https://stralo.polsia.app/api/stripe-billing/verify \
  -G --data-urlencode "session_id=cs_test_a1b2c3d4e5f6g7h8"
POST/api/webhooks
201
Bearer required

Register an outbound-webhook subscription for booking events. Idempotent on the (agentId, eventType, url) unique index — re-POSTing the same triple is a no-op rather than a duplicate row. agentId is always bearer-derived; the body schema rejects it.

Required headers

  • Authorization: Bearer <YOUR_API_KEY> — the plaintext sk_<uuid> emitted once by POST /api/agents.
  • Content-Type: application/json

Request body

{
  "url":       "https://…",        // required, must be a valid https URL
  "eventType": "booking.created"   // required, one of:
                                   //   booking.created
                                   //   booking.cancelled
                                   //   booking.reminder
}

// agentId is NEVER trusted from the body — the schema rejects it, and the
// route strips it defensively. The subscription belongs to the bearer.

Error codes

  • 401unauthorizedMissing or unparseable Authorization header.
  • 400Bad RequestBody failed Zod validation — { errors: { url?, eventType? } }. url must be a valid https URL; eventType must be one of booking.created | booking.cancelled | booking.reminder.
  • 500Internal Server ErrorUnexpected helper failure past the contract guard.

Response

{
  "id":        "wh_91a4bbe028164a3a8e2a5be1",
  "agentId":   "<YOUR_AGENT_ID>",
  "url":       "https://example.com/stralo-events",
  "eventType": "booking.created",
  "createdAt": "2026-08-18T15:40:26.046Z"
}

curl

POST/api/webhooks
curl -X POST https://stralo.polsia.app/api/webhooks \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <YOUR_API_KEY>" \
  -d '{
    "url":       "https://example.com/stralo-events",
    "eventType": "booking.created"
  }'
DELETE/api/webhooks/{id}
204
Bearer required

Remove an outbound webhook subscription. Soft-deletes by deleting the subscription row (pending and failed deliveries cascade). 404 is returned both for missing ids AND for ids that don't belong to the caller, so a subscription id cannot be probed against another agent.

Required headers

  • Authorization: Bearer <YOUR_API_KEY> — the plaintext sk_<uuid> emitted once by POST /api/agents.

Error codes

  • 401unauthorizedMissing or unparseable Authorization header.
  • 404not_foundNo subscription matches {id} for this bearer — same code for missing and wrong-owner.
  • 500Internal Server ErrorUnexpected helper failure past the contract guard.

Response

(204 — no body)

The subscription row is gone; pending and failed WebhookDelivery rows for this
subscription cascade-delete via the FK onDelete: Cascade.

curl

DELETE/api/webhooks/{id}
curl -X DELETE https://stralo.polsia.app/api/webhooks/wh_91a4bbe028164a3a8e2a5be1 \
  -H "Authorization: Bearer <YOUR_API_KEY>"
Reads

Payments model — why there is no /api/stripe/webhook

STRALO does not run an inbound Stripe webhook endpoint. Checkout and fulfillment travel through POST /api/stripe-billing/checkout (hosted-checkout redirect) and GET /api/stripe-billing/verify (success-page verification). Stripe push events are consumed by the Polsia payment proxy onto a server-side payment-events feed; the app fulfils against that feed (verify-on-success plus an optional cursor poll via listPaymentEvents / processNewPaymentEvents) — the app never needs to mount a public callback URL itself.

The OUTBOUND webhooks — booking.created, booking.cancelled, and booking.reminder — that STRALO DOES fire are registered through POST /api/webhooks: subscribers receive a HMAC-signed JSON POST against their own URL. There is one webhook signer per agent; it is surfaced by POST /api/agents/me/webhook-secret exactly once, mirroring the API-key rotation pattern.

Money flow
inbound → outbound → durable
1. Browser → POST /api/stripe-billing/checkout
   → server prices, returns hosted-checkout URL

2. Buyer → Stripe-hosted checkout
   → Stripe webhooks → Polsia payment proxy
   → payment-events feed (server-side, not exposed)

3. Browser → /checkout/success?session_id=…
   → GET /api/stripe-billing/verify
   → server reads payment-events feed, returns verified payload

4. STRALO → fanout booking.created to subscribed URLs
   → POST /api/webhooks subscribers

Drift

Every curl example and status code on this page comes straight from the matching src/lib/contracts/*.ts and src/app/api/**/route.ts. If you add a new endpoint, change a body shape, or remap a status code, update the route handler and this page in the same change — a curl that no longer mirrors the live shape is worse than no curl. When in doubt, run /quickstart end-to-end before shipping.