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.
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.
/api/agentsMint 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
- 400
invalid_request— Body is not valid JSON. - 400
Bad Request— Zod validation failed — body is { errors: { name?, contactEmail? } }. - 500
Internal Server Error— Helper insert raised past the contract guard.
Response
{
"agentId": "ag_5b8e3a1c9c2b4e1c8f7d6a5b",
"apiKey": "sk_<redacted — surfaced once, never re-fetchable>"
}curl
curl -X POST https://stralo.polsia.app/api/agents \
-H "Content-Type: application/json" \
-d '{}'/api/bookingsConfirm 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 plaintextsk_<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
- 401
unauthorized— Missing or unparseable Authorization header. - 400
Bad Request— Body failed Zod validation — { errors: { agentId?, startsAt?, endsAt? } }. endsAt must be after startsAt. - 403
forbidden— Bearer resolved, but bearer.agentId != body.agentId. - 409
slot_taken— The window overlaps an existing confirmed row on this agentId — the route maps Postgres 23P01 to 409. - 429
booking_cap_reached— Free-tier agent cap exceeded (2 agents). - 500
Internal Server Error— Unexpected 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
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"
}'/api/bookingsList 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 plaintextsk_<uuid>emitted once by POST /api/agents.
Error codes
- 401
unauthorized— Missing or unparseable Authorization header. - 400
Bad Request— Query failed Zod validation — { errors: { limit?, from?, to? } }. to must be on or after from. - 500
Internal Server Error— Unexpected 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
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"
/api/bookings/{id}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 plaintextsk_<uuid>emitted once by POST /api/agents.
Error codes
- 401
unauthorized— Missing or unparseable Authorization header. - 403
forbidden— Row exists but does not belong to this bearer. - 404
not_found— No row matches {id} — already-deleted, never existed, or wrong id. - 500
Internal Server Error— Unexpected 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
curl -X DELETE https://stralo.polsia.app/api/bookings/bk_4f1a93de8c7240cda0f3b9e2 \ -H "Authorization: Bearer <YOUR_API_KEY>"
/api/bookings/{id}/occurrencesExpand 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 plaintextsk_<uuid>emitted once by POST /api/agents.
Error codes
- 401
unauthorized— Missing or unparseable Authorization header. - 403
forbidden— Row exists but does not belong to this bearer. - 404
not_found— No row matches {id}. - 400
bad_rrule— The stored RRULE did not parse — body { error: "bad_rrule", message }. The row stays; cancel + re-create it. - 500
Internal Server Error— Unexpected 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
curl https://stralo.polsia.app/api/bookings/bk_4f1a93de8c7240cda0f3b9e2/occurrences \ -H "Authorization: Bearer <YOUR_API_KEY>" \ -G --data-urlencode "limit=5"
/api/proposalsOpen 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 plaintextsk_<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
- 401
unauthorized— Missing or unparseable Authorization header. - 400
Bad Request— Body failed Zod validation — { errors: { targetAgentId?, startsAt?, endsAt? } }. endsAt must be after startsAt. - 500
Internal Server Error— Unexpected 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
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"
}'/api/proposals/{id}/acceptThe 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 plaintextsk_<uuid>emitted once by POST /api/agents.Content-Type:application/json
Error codes
- 401
unauthorized— Missing or unparseable Authorization header. - 403
forbidden— Proposal exists but the bearer is not the target agent. - 404
not_found— No proposal matches {id}. - 409
proposal_not_pending— Proposal is already accepted or declined. - 409
slot_taken— The transfer window overlaps an existing confirmed Booking on the PROPOSER calendar — the EXCLUDE constraint raised 23P01; the proposal flip was rolled back. - 500
Internal Server Error— Unexpected 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
curl -X PATCH https://stralo.polsia.app/api/proposals/pr_3a82c1e0bdfe4d39b58d2ac9/accept \ -H "Authorization: Bearer <YOUR_API_KEY>"
/api/proposals/{id}/rejectThe 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 plaintextsk_<uuid>emitted once by POST /api/agents.Content-Type:application/json
Error codes
- 401
unauthorized— Missing or unparseable Authorization header. - 403
forbidden— Proposal exists but the bearer is not the target agent. - 404
not_found— No proposal matches {id}. - 409
proposal_not_pending— Proposal is already accepted or raced past pending. - 500
Internal Server Error— Unexpected 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
curl -X PATCH https://stralo.polsia.app/api/proposals/pr_3a82c1e0bdfe4d39b58d2ac9/reject \ -H "Authorization: Bearer <YOUR_API_KEY>"
/api/stripe-billing/checkoutStart 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
- 400
invalid_request— Body failed validation. productId is required. - 404
unknown_product— productId is not in CATALOG — server-side, not the browser. - 503
payments_not_enabled— Polsia payments aren't enabled for this app yet. - 503
stripe_billing_not_configured— Stripe isn't fully configured server-side. Email stralo@polsia.app for help. - 502
checkout_failed— Checkout session could not be created — retry.
Response
{
"url": "https://checkout.stripe.com/c/pay/cs_test_…#:~:text=…"
}curl
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" }'/api/stripe-billing/verifyVerify 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
- 400
Bad Request— session_id is missing or malformed. - 503
stripe_billing_not_configured— Stripe server config missing — cannot verify yet. - 502
payment_verification_failed— Verification 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
curl https://stralo.polsia.app/api/stripe-billing/verify \ -G --data-urlencode "session_id=cs_test_a1b2c3d4e5f6g7h8"
/api/webhooksRegister 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 plaintextsk_<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
- 401
unauthorized— Missing or unparseable Authorization header. - 400
Bad Request— Body failed Zod validation — { errors: { url?, eventType? } }. url must be a valid https URL; eventType must be one of booking.created | booking.cancelled | booking.reminder. - 500
Internal Server Error— Unexpected 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
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"
}'/api/webhooks/{id}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 plaintextsk_<uuid>emitted once by POST /api/agents.
Error codes
- 401
unauthorized— Missing or unparseable Authorization header. - 404
not_found— No subscription matches {id} for this bearer — same code for missing and wrong-owner. - 500
Internal Server Error— Unexpected 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
curl -X DELETE https://stralo.polsia.app/api/webhooks/wh_91a4bbe028164a3a8e2a5be1 \ -H "Authorization: Bearer <YOUR_API_KEY>"
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.
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.