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.

Lost the original first-agent key?
Owner-authenticated recovery is destructive: it removes calendar state and never returns a replacement key from the recovery endpoint.
Auth, in one paragraph

Every endpoint below requires one agent credential header: either X-API-Key: <sk_…> or Authorization: Bearer <sk_…> — send one, never both. 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. The curl on this page is machine-readable at /openapi.json.

MCP transport
Streamable HTTP

Connect with JSON-RPC over POST.

STRALO exposes a Streamable-HTTP MCP transport at /api/mcp. Start with the public /mcp.json manifest, then send JSON-RPC 2.0 frames with X-API-Key and reuse the returned Mcp-Session-Id. The full REST metadata remains available from /openapi.json.

Important: this is not legacy SSE
There is no EventSource connection or text/event-stream response here.

Use a native HTTP client that can POST JSON bodies and read JSON responses. A GET /api/mcp probe is intentionally rejected with 405 and the message Use POST JSON-RPC 2.0 frames against /api/mcp..

The discovery documents are public. The eight registered tools are protected; every tool request must send exactly one credential header. These examples use the recommended X-API-Key form.

JSON-RPC envelope
request → response / error
// Request
{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/list",
  "params": {}
}

// Success
{ "jsonrpc": "2.0", "id": 2, "result": { ... } }

// Error
{ "jsonrpc": "2.0", "id": 2,
  "error": { "code": -32601, "message": "...", "data": {} } }

1. Discover the transport

Fetch /mcp.json to read the server descriptor, the eight tool names, and the absolute Streamable-HTTP URL. Use /openapi.json for the underlying REST schemas.

GET/mcp.json
curl https://stralo.polsia.app/mcp.json

2. Initialize a session

POST an initialize frame with your API key. The 200 response includes a Mcp-Session-Id header; retain it for the next frames.

POST · 200 + session header/api/mcp · initialize
curl -i -X POST https://stralo.polsia.app/api/mcp \
  -H "Content-Type: application/json" \
  -H "X-API-Key: <YOUR_API_KEY>" \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "initialize",
    "params": {
      "protocolVersion": "2025-03-26",
      "capabilities": {},
      "clientInfo": { "name": "my-agent", "version": "1.0.0" }
    }
  }'

# Response headers include:
# Mcp-Session-Id: mcp_<server-issued-session-id>
# The JSON body contains result.protocolVersion, serverInfo, and capabilities.

3. Acknowledge initialization

Send the notification without an id and include the returned session id. The endpoint acknowledges it with 202 Accepted and an empty body.

POST · 202 Accepted/api/mcp · notifications/initialized
SESSION_ID="<MCP_SESSION_ID_FROM_INITIALIZE>"
curl -i -X POST https://stralo.polsia.app/api/mcp \
  -H "Content-Type: application/json" \
  -H "X-API-Key: <YOUR_API_KEY>" \
  -H "Mcp-Session-Id: $SESSION_ID" \
  -d '{
    "jsonrpc": "2.0",
    "method": "notifications/initialized"
  }'

# Response: HTTP/1.1 202 Accepted with an empty body.

4. List the tools

POST a request frame with a numeric or string id. The result contains the input schema and descriptions for all eight tools. The post_agents tool is conditional: use bootstrap: true without credentials only for the first-agent claim; all other tools require a saved key.

POST · 200 OK/api/mcp · tools/list
curl -X POST https://stralo.polsia.app/api/mcp \
  -H "Content-Type: application/json" \
  -H "X-API-Key: <YOUR_API_KEY>" \
  -H "Mcp-Session-Id: $SESSION_ID" \
  -d '{
    "jsonrpc": "2.0",
    "id": 2,
    "method": "tools/list",
    "params": {}
  }'

Native Node fetch

This complete example performs discovery-independent initialization, extracts the session header, sends the 202 notification, and prints the tools/list JSON. It uses fetch only — no EventSource, SSE client, server fetch, database import, or Server Action.

Node.js 20+mcp-transport.mjs
const endpoint = 'https://stralo.polsia.app/api/mcp';
const apiKey = process.env.STRALO_API_KEY;

if (!apiKey) throw new Error('Set STRALO_API_KEY first');

const jsonHeaders = {
  'Content-Type': 'application/json',
  'X-API-Key': apiKey,
};

const initializeResponse = await fetch(endpoint, {
  method: 'POST',
  headers: jsonHeaders,
  body: JSON.stringify({
    jsonrpc: '2.0',
    id: 1,
    method: 'initialize',
    params: {
      protocolVersion: '2025-03-26',
      capabilities: {},
      clientInfo: { name: 'node-example', version: '1.0.0' },
    },
  }),
});

if (!initializeResponse.ok) {
  throw new Error(
    `initialize failed: ${initializeResponse.status} ${await initializeResponse.text()}`,
  );
}

const sessionId = initializeResponse.headers.get('mcp-session-id');
if (!sessionId) throw new Error('Mcp-Session-Id was not returned');
console.log('initialize', await initializeResponse.json());

const initializedResponse = await fetch(endpoint, {
  method: 'POST',
  headers: { ...jsonHeaders, 'Mcp-Session-Id': sessionId },
  body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
});
if (initializedResponse.status !== 202) {
  throw new Error(`notifications/initialized failed: ${initializedResponse.status}`);
}

const toolsResponse = await fetch(endpoint, {
  method: 'POST',
  headers: { ...jsonHeaders, 'Mcp-Session-Id': sessionId },
  body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }),
});
if (!toolsResponse.ok) {
  throw new Error(`tools/list failed: ${toolsResponse.status} ${await toolsResponse.text()}`);
}
console.log('tools/list', await toolsResponse.json());
Shipped tool names
The registry is a 1:1 wrapper around the existing REST routes.
post_agentspost_bookingsget_bookingsdelete_bookings_idpost_proposalspatch_proposals_id_acceptpatch_proposals_id_rejectget_bookings_id_occurrences
Auth and errors

Use exactly one of X-API-Key or Authorization: Bearer. The examples use X-API-Key.

JSON-RPC errors use the standard error.code and error.message fields. Tool failures retain the inner REST status in the returned result metadata.

Agents management

Bootstrap the first agent, then keep its key safe.

The live agent-management contract currently exposes one operation: POST /api/agents. It lets an already-authenticated agent create a child agent and returns that child's plaintext API key exactly once. A brand-new installation can instead send bootstrap: true without a credential to claim its first agent.

Authentication
Send exactly one credential header on the create request and on later agent API calls.

X-API-Key is the recommended form. The API also accepts Authorization: Bearer <YOUR_API_KEY> as a compatible fallback. Do not send both headers.

For the first agent, omit credentials and send bootstrap: true. The new public_token is returned only in the 201 response and cannot be recovered later; store it securely before making requests. Repeated bootstrap returns a non-secret 409.

Create shape
This is the exact `AgentCreate` contract in the live OpenAPI document.
{
  "name": "calendar-agent",                         // required, 1–120 chars
  "metadata": { "team": "operations" },            // optional JSON object
  "config": { "timezone": "UTC" },                 // optional JSON object
  "idempotency_key": "calendar-agent-2026-08-28",   // optional, max 255 chars
  "bootstrap": true                                  // first-agent mode only
}

Response shape

{
  "id": "ag_5b8e3a1c9c2b4e1c8f7d6a5b",
  "public_token": "sk_<redacted — surfaced once>",
  "created_at": "2026-08-31T12:00:00.000Z",
  "next_step": {
    "save_public_token": true,
    "authorization": "Authorization: Bearer <public_token>",
    "x_api_key": "X-API-Key: <public_token>"
  }
}

A successful request returns 201 and an Agent object. The stored database value is only a hash of public_token.

Heartbeats and status lifecycle

This checkout does not expose an agent heartbeat endpoint or heartbeat field, so it has no live ISO-8601 heartbeat requirement to document. It also does not expose an agent status field or status-transition route, and there is no PATCH or PUT agent-update operation in the live OpenAPI contract. Do not treat /api/status as an agent lifecycle endpoint; it reports overall application/webhook health.

Create request

POST/api/agents
curl -X POST https://stralo.polsia.app/api/agents \
  -H "Content-Type: application/json" \
  -d '{
    "name": "calendar-agent",
    "bootstrap": true,
    "metadata": { "team": "operations" },
    "config": { "timezone": "UTC" }
  }'

Validation and errors

  • 400 invalid JSON:{ error: "bad_request", message: "Body must be valid JSON." }
  • 400 schema failure:{ errors: { field: "first validation message" } }
  • 401 missing or invalid credential: { error, message }.
  • 409 reused idempotency_key: { error: "conflict", message }.
  • 500 unexpected server failure: { error: "Internal Server Error" }.
POST/api/agents
201
Bootstrap or agent credential

Bootstrap the first agent with bootstrap:true, or create a child seat with an existing agent credential. The plaintext public token is returned in the 201 body exactly once; the database stores only its SHA-256 hash.

Required headers

  • First agent only: omit credentials and send bootstrap: true. Once an agent exists, send exactly one X-API-Key or Authorization: Bearer header. A repeated bootstrap returns 409 with no token.
  • Content-Type: application/json

Request body

{
  "name": "calendar-agent",                         // required, 1-120 chars
  "metadata": { "team": "operations" },            // optional JSON object
  "config": { "timezone": "UTC" },                 // optional JSON object
  "idempotency_key": "calendar-agent-2026-08-28",   // optional, max 255 chars
  "bootstrap": true                                  // only for the first agent
}

For a brand-new installation, omit credentials and send bootstrap:true. After
the first agent exists, send an existing credential in Authorization: Bearer or
X-API-Key. The response contains the plaintext public_token exactly once; save
it before using it for subsequent calls.

The plaintext public token is returned once. Save it, then use it as the credential for the new agent's later calls.

Error codes

  • 400bad_request— Body is not valid JSON.
  • 400bad_request— Zod validation failed — body is { errors: { name, metadata?, config?, idempotency_key?, bootstrap? } }.
  • 401unauthorized— Missing credential (use bootstrap:true only for the first agent) or a supplied invalid credential.
  • 409bootstrap_claimed— The first agent already exists; no public_token is returned. Use an existing key or the dashboard.
  • 409conflict— The idempotency_key has already been used.
  • 500Internal Server Error— Helper insert raised past the contract guard.

Response

{
  "id": "ag_5b8e3a1c9c2b4e1c8f7d6a5b",
  "public_token": "sk_<redacted — surfaced once, never re-fetchable>",
  "created_at": "2026-10-03T11:52:55.727Z",
  "next_step": {
    "save_public_token": true,
    "authorization": "Authorization: Bearer <public_token>",
    "x_api_key": "X-API-Key: <public_token>"
  }
}

curl

POST/api/agents
curl -X POST https://stralo.polsia.app/api/agents \
  -H "Content-Type: application/json" \
  -d '{
    "name": "calendar-agent",
    "bootstrap": true,
    "metadata": { "team": "operations" },
    "config": { "timezone": "UTC" }
  }'
POST/api/bookings
201
Agent credential required

Confirm a booking on the authenticated agent's calendar. The resolved agentId MUST match the body's agentId — a credential 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

  • Use exactly one: X-API-Key: <YOUR_API_KEY> (recommended) or Authorization: Bearer <YOUR_API_KEY> (retained fallback). The plaintext sk_<uuid> is emitted once by POST /api/agents.
  • Content-Type: application/json

Request body

{
  "agentId":  "<YOUR_AGENT_ID>",   // required, 1-128 chars
  "startsAt": "2026-09-11T00:27:38.378Z",      // required, RFC 3339 with offset
  "endsAt":   "2026-09-11T01:27:38.378Z"         // required, RFC 3339 with offset, endsAt > startsAt
}

Error codes

  • 401unauthorized— Missing or unparseable Authorization or X-API-Key header.
  • 400Bad Request— Body failed Zod validation — { errors: { agentId?, startsAt?, endsAt? } }. endsAt must be after startsAt.
  • 403forbidden— Credential resolved, but credential.agentId != body.agentId.
  • 409slot_taken— The window overlaps an existing confirmed row on this agentId — the route maps Postgres 23P01 to 409.
  • 429booking_cap_reached— Free-tier agent cap exceeded (2 agents).
  • 500Internal Server Error— Unexpected helper failure past the contract guard.

Response

{
  "id":             "bk_4f1a93de8c7240cda0f3b9e2",
  "agentId":        "<YOUR_AGENT_ID>",
  "startsAt":       "2026-09-11T00:27:38.378Z",
  "endsAt":         "2026-09-11T01:27:38.378Z",
  "status":         "confirmed",
  "rrule":          null,
  "warningSentAt":  null,
  "createdAt":      "2026-10-03T11:52:55.728Z"
}

curl

POST/api/bookings
curl -X POST https://stralo.polsia.app/api/bookings \
  -H "Content-Type: application/json" \
  -H "X-API-Key: <YOUR_API_KEY>" \
  -d '{
    "agentId":  "<YOUR_AGENT_ID>",
    "startsAt": "2026-09-11T00:27:38.378Z",
    "endsAt":   "2026-09-11T01:27:38.378Z"
  }'
GET/api/bookings
200
Agent credential required

List confirmed AND cancelled rows that belong to the authenticated agent, 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

  • Use exactly one: X-API-Key: <YOUR_API_KEY> (recommended) or Authorization: Bearer <YOUR_API_KEY> (retained fallback). The plaintext sk_<uuid> is emitted once by POST /api/agents.

Error codes

  • 401unauthorized— Missing or unparseable Authorization or X-API-Key header.
  • 400Bad Request— Query failed Zod validation — { errors: { limit?, from?, to? } }. to must be on or after from.
  • 500Internal Server Error— Unexpected helper failure past the contract guard.

Response

{
  "items": [
    {
      "id":             "bk_4f1a93de8c7240cda0f3b9e2",
      "agentId":        "<YOUR_AGENT_ID>",
      "startsAt":       "2026-09-11T00:27:38.378Z",
      "endsAt":         "2026-09-11T01:27:38.378Z",
      "status":         "confirmed",
      "rrule":          null,
      "warningSentAt":  null,
      "createdAt":      "2026-10-03T11:52:55.728Z"
    }
  ]
}

curl

GET/api/bookings
curl https://stralo.polsia.app/api/bookings \
  -H "X-API-Key: <YOUR_API_KEY>" \
  -G --data-urlencode "limit=50" \
  --data-urlencode "from=2026-09-11T00:27:38.378Z"
DELETE/api/bookings/{id}
204
Agent credential 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 authenticated agent must own the row; cross-agent cancellation is structurally impossible because the UPDATE is keyed by credential-derived agentId.

Required headers

  • Use exactly one: X-API-Key: <YOUR_API_KEY> (recommended) or Authorization: Bearer <YOUR_API_KEY> (retained fallback). The plaintext sk_<uuid> is emitted once by POST /api/agents.

Error codes

  • 401unauthorized— Missing or unparseable Authorization or X-API-Key header.
  • 403forbidden— Row exists but does not belong to this authenticated agent.
  • 404not_found— No row matches {id} — already-deleted, never existed, or wrong id.
  • 500Internal 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

DELETE/api/bookings/{id}
curl -X DELETE https://stralo.polsia.app/api/bookings/bk_4f1a93de8c7240cda0f3b9e2 \
  -H "X-API-Key: <YOUR_API_KEY>"
GET/api/bookings/{id}/occurrences
200
Agent credential 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 authenticated agent.

Required headers

  • Use exactly one: X-API-Key: <YOUR_API_KEY> (recommended) or Authorization: Bearer <YOUR_API_KEY> (retained fallback). The plaintext sk_<uuid> is emitted once by POST /api/agents.

Error codes

  • 401unauthorized— Missing or unparseable Authorization or X-API-Key header.
  • 403forbidden— Row exists but does not belong to this authenticated agent.
  • 404not_found— No row matches {id}.
  • 400bad_rrule— The stored RRULE did not parse — body { error: "bad_rrule", message }. The row stays; cancel + re-create it.
  • 500Internal Server Error— Unexpected helper failure past the contract guard.

Response

{
  "items": [
    {
      "startsAt": "2026-09-17T23:27:38.378Z",
      "endsAt":   "2026-09-18T00:27:38.378Z"
    },
    {
      "startsAt": "2026-09-24T23:27:38.378Z",
      "endsAt":   "2026-09-25T00:27:38.378Z"
    }
  ]
}

curl

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

Open a slot-transfer proposal — ask another agent's calendar for a window. proposerAgentId is set server-side from the authenticated credential, 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

  • Use exactly one: X-API-Key: <YOUR_API_KEY> (recommended) or Authorization: Bearer <YOUR_API_KEY> (retained fallback). The plaintext sk_<uuid> is emitted once by POST /api/agents.
  • Content-Type: application/json

Request body

{
  "targetAgentId": "ag_2d7c44f1800f4bc9b370f93e",  // required, 1-128 chars
  "startsAt":      "2026-09-11T02:27:38.378Z", // required, RFC 3339 with offset
  "endsAt":        "2026-09-11T03:27:38.378Z"    // required, RFC 3339 with offset, endsAt > startsAt
}

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

Error codes

  • 401unauthorized— Missing or unparseable Authorization or X-API-Key header.
  • 400Bad Request— Body failed Zod validation — { errors: { targetAgentId?, startsAt?, endsAt? } }. endsAt must be after startsAt.
  • 500Internal Server Error— Unexpected helper failure past the contract guard.

Response

{
  "id":               "pr_3a82c1e0bdfe4d39b58d2ac9",
  "proposerAgentId":  "<YOUR_AGENT_ID>",
  "targetAgentId":    "ag_2d7c44f1800f4bc9b370f93e",
  "startsAt":         "2026-09-11T02:27:38.378Z",
  "endsAt":           "2026-09-11T03:27:38.378Z",
  "status":           "pending",
  "createdAt":        "2026-10-03T11:52:55.728Z"
}

curl

POST/api/proposals
curl -X POST https://stralo.polsia.app/api/proposals \
  -H "Content-Type: application/json" \
  -H "X-API-Key: <YOUR_API_KEY>" \
  -d '{
    "targetAgentId": "ag_2d7c44f1800f4bc9b370f93e",
    "startsAt":      "2026-09-11T02:27:38.378Z",
    "endsAt":        "2026-09-11T03:27:38.378Z"
  }'
PATCH/api/proposals/{id}/accept
200
Agent credential 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

  • Use exactly one: X-API-Key: <YOUR_API_KEY> (recommended) or Authorization: Bearer <YOUR_API_KEY> (retained fallback). The plaintext sk_<uuid> is emitted once by POST /api/agents.
  • Content-Type: application/json

Error codes

  • 401unauthorized— Missing or unparseable Authorization or X-API-Key header.
  • 403forbidden— Proposal exists but the authenticated agent is not the target agent.
  • 404not_found— No proposal matches {id}.
  • 409proposal_not_pending— Proposal is already accepted or declined.
  • 409slot_taken— The transfer window overlaps an existing confirmed Booking on the PROPOSER calendar — the EXCLUDE constraint raised 23P01; the proposal flip was rolled back.
  • 500Internal Server Error— Unexpected helper failure past the contract guard.

Response

{
  "id":               "pr_3a82c1e0bdfe4d39b58d2ac9",
  "proposerAgentId":  "<YOUR_AGENT_ID>",
  "targetAgentId":    "ag_2d7c44f1800f4bc9b370f93e",
  "startsAt":         "2026-09-11T02:27:38.378Z",
  "endsAt":           "2026-09-11T03:27:38.378Z",
  "status":           "accepted",
  "createdAt":        "2026-09-11T00:27:38.378Z"
}

curl

PATCH/api/proposals/{id}/accept
curl -X PATCH https://stralo.polsia.app/api/proposals/pr_3a82c1e0bdfe4d39b58d2ac9/accept \
  -H "X-API-Key: <YOUR_API_KEY>"
PATCH/api/proposals/{id}/reject
200
Agent credential 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

  • Use exactly one: X-API-Key: <YOUR_API_KEY> (recommended) or Authorization: Bearer <YOUR_API_KEY> (retained fallback). The plaintext sk_<uuid> is emitted once by POST /api/agents.
  • Content-Type: application/json

Error codes

  • 401unauthorized— Missing or unparseable Authorization or X-API-Key header.
  • 403forbidden— Proposal exists but the authenticated agent is not the target agent.
  • 404not_found— No proposal matches {id}.
  • 409proposal_not_pending— Proposal is already accepted or raced past pending.
  • 500Internal Server Error— Unexpected helper failure past the contract guard.

Response

{
  "id":               "pr_3a82c1e0bdfe4d39b58d2ac9",
  "proposerAgentId":  "<YOUR_AGENT_ID>",
  "targetAgentId":    "ag_2d7c44f1800f4bc9b370f93e",
  "startsAt":         "2026-09-11T02:27:38.378Z",
  "endsAt":           "2026-09-11T03:27:38.378Z",
  "status":           "pending",
  "createdAt":        "2026-09-11T00:27:38.378Z"
}

curl

PATCH/api/proposals/{id}/reject
curl -X PATCH https://stralo.polsia.app/api/proposals/pr_3a82c1e0bdfe4d39b58d2ac9/reject \
  -H "X-API-Key: <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_request— Body failed validation. productId is required.
  • 404unknown_product— productId is not in CATALOG — server-side, not the browser.
  • 503payments_not_enabled— Polsia payments aren't enabled for this app yet.
  • 503stripe_billing_not_configured— Stripe isn't fully configured server-side. Email stralo@polsia.app for help.
  • 502checkout_failed— Checkout 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 Request— session_id is missing or malformed.
  • 503stripe_billing_not_configured— Stripe server config missing — cannot verify yet.
  • 502payment_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-10-03T11:52:55.728Z"
}

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
Agent credential 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 credential-derived; the body schema rejects it.

Required headers

  • Use exactly one: X-API-Key: <YOUR_API_KEY> (recommended) or Authorization: Bearer <YOUR_API_KEY> (retained fallback). The plaintext sk_<uuid> is 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
                                   //   proposal.accepted
                                   //   proposal.rejected
}

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

Error codes

  • 401unauthorized— Missing or unparseable Authorization or X-API-Key header.
  • 400Bad 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.
  • 500Internal 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-10-03T11:52:55.728Z"
}

curl

POST/api/webhooks
curl -X POST https://stralo.polsia.app/api/webhooks \
  -H "Content-Type: application/json" \
  -H "X-API-Key: <YOUR_API_KEY>" \
  -d '{
    "url":       "https://example.com/stralo-events",
    "eventType": "booking.created"
  }'
DELETE/api/webhooks/{id}
204
Agent credential 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

  • Use exactly one: X-API-Key: <YOUR_API_KEY> (recommended) or Authorization: Bearer <YOUR_API_KEY> (retained fallback). The plaintext sk_<uuid> is emitted once by POST /api/agents.

Error codes

  • 401unauthorized— Missing or unparseable Authorization or X-API-Key header.
  • 404not_found— No subscription matches {id} for this authenticated agent — same code for missing and wrong-owner.
  • 500Internal 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

DELETE/api/webhooks/{id}
curl -X DELETE https://stralo.polsia.app/api/webhooks/wh_91a4bbe028164a3a8e2a5be1 \
  -H "X-API-Key: <YOUR_API_KEY>"
Reference

Webhook event shapes — payload + signature verification

The five event types booking.created, booking.cancelled, booking.reminder, proposal.accepted, and proposal.rejected flow through the same outbound pipeline: every POST is JSON-bodied and signed with X-Polsa-Signature as a Stripe-style HMAC of the signing string (timestamp dot-prefixed to the raw JSON body). The decision and decidedAt fields on the proposal.* events echo the credential-derived decision so a subscriber can render the outcome without re-fetching the proposal row.

booking.created / booking.cancelled / booking.reminder carry booking id, agentId, startsAt, endsAt, status, and eventType — and a cancelledAt on booking.cancelled. The proposal.* events carry proposal id, recipient agentId, counterpartyId, decision, and decidedAt instead. Both shapes are documented inline below so a subscriber can wire verification + branching without a round-trip to the REST surface.

Verify with the shipped verifyWebhookSignature helper: read X-Polsa-Signature, parse its t timestamp in milliseconds, and authenticate the exact raw body. The canonical header is X-Polsa-Signature: t=<timestamp_ms>,v1=<64 hex>; the signed bytes are ${timestamp_ms}.${rawBody}. No separate timestamp header is required. The helper rejects malformed headers, unsafe timestamps, empty secrets, mismatched HMACs, and timestamps more than 300,000 ms from Date.now(). X-Polsa-Delivery-Id and X-Polsa-Event-Type are tracing and routing metadata only — do not use either as an authentication input. The complete receiver recipe is in the webhook verification guide.

proposal.accepted payload
PATCH /api/proposals/{id}/accept — one delivery per side
{
  "eventType":      "proposal.accepted",
  "id":             "pr_3a82c1e0bdfe4d39b58d2ac9",
  "agentId":        "ag_5b8e3a1c9c2b4e1c8f7d6a5b",
  "counterpartyId": "ag_2d7c44f1800f4bc9b370f93e",
  "decision":       "accepted",
  "decidedAt":      "2026-09-11T00:27:38.378Z"
}
proposal.rejected payload
PATCH /api/proposals/{id}/reject — one delivery per side
{
  "eventType":      "proposal.rejected",
  "id":             "pr_3a82c1e0bdfe4d39b58d2ac9",
  "agentId":        "ag_2d7c44f1800f4bc9b370f93e",
  "counterpartyId": "ag_5b8e3a1c9c2b4e1c8f7d6a5b",
  "decision":       "rejected",
  "decidedAt":      "2026-09-11T00:27:38.378Z"
}
Signature header — canonical wire format
X-Polsa-Signature, X-Polsa-Delivery-Id, X-Polsa-Event-Type
# Headers on every outbound POST
X-Polsa-Signature:   t=<timestamp_ms>,v1=<64 lowercase/uppercase hex>
X-Polsa-Delivery-Id: wh_<cuid>
X-Polsa-Event-Type:  booking.created | booking.cancelled | booking.reminder |
                     proposal.accepted | proposal.rejected

# Signed bytes = ${timestamp_ms}.${rawBody}
# Reject if abs(Date.now() - timestamp_ms) > 300000
# No separate timestamp header is required
# Delivery ID and event type are tracing/routing metadata only
Security guide

Verify outbound webhooks

Register a destination with POST /api/webhooks and verify every delivery before parsing or processing it. The event payloads and their five supported event names are listed in the webhook event reference.

Canonical contract
The timestamp is part of the signed header; no second timestamp header is needed.
X-Polsa-Signature: t=<timestamp_ms>,v1=<64 hex>
HMAC-SHA-256(secret, <timestamp_ms> + "." + rawBody)
  • Read the body once as raw text. Do not parse, pretty-print, reorder, or re-serialize it before verification.
  • Pass the raw text, header value, and the one-time webhookSigner secret to verifyWebhookSignature from stralo-js v0.2.0.
  • The helper rejects malformed headers, unsafe timestamps, empty secrets, mismatched HMACs, and timestamps more than 300,000 ms from Date.now().
Companion headers
Useful for tracing and routing, but not authentication inputs.

X-Polsa-Delivery-Id is a stable delivery identifier. Use it as the key in your durable replay store.

X-Polsa-Event-Type names the event for logs and routing. Do not trust it as proof of authenticity; authenticate the raw body with X-Polsa-Signature first.

The signer is returned once by POST /api/agents/me/webhook-secret and belongs in STRALO_WEBHOOK_SIGNER on the receiver.

Send a signed fixture with curl
The same JSON string is signed and sent unchanged with --data-binary.
curlhttps://example.com/stralo-events
: "${STRALO_WEBHOOK_SIGNER:?Set STRALO_WEBHOOK_SIGNER first}"
payload='{"id":"bk_fixture_123","agentId":"ag_fixture_123","startsAt":"2026-08-30T12:00:00.000Z","endsAt":"2026-08-30T13:00:00.000Z","status":"confirmed","eventType":"booking.created"}'
timestamp=$(date +%s%3N)
signature=$(printf '%s.%s' "$timestamp" "$payload" |   openssl dgst -sha256 -hmac "$STRALO_WEBHOOK_SIGNER" -hex |   awk '{print $NF}')

curl -X POST "https://example.com/stralo-events"   -H "Content-Type: application/json"   -H "X-Polsa-Signature: t=$timestamp,v1=$signature"   -H "X-Polsa-Delivery-Id: wh_fixture_01J6Q8M7K2"   -H "X-Polsa-Event-Type: booking.created"   --data-binary "$payload"

Set STRALO_WEBHOOK_SIGNER to the secret from the agent secret endpoint before running the fixture. The date +%s%3N command emits the required millisecond timestamp.

Receive and verify in Node.js
Verify first, claim the delivery ID, then parse and process.
Node.jsreceiver.ts
import { verifyWebhookSignature } from 'stralo-js';

// Demo only: replace this process-local set with a durable, atomic store.
const processedDeliveryIds = new Set<string>();

async function processEvent(event: unknown): Promise<void> {
  if (!event || typeof event !== 'object') {
    throw new Error('webhook payload must be a JSON object');
  }
  // Route booking.created, booking.cancelled, booking.reminder,
  // proposal.accepted, and proposal.rejected here.
}

export async function POST(request: Request): Promise<Response> {
  const rawBody = await request.text();
  const signatureHeader = request.headers.get('X-Polsa-Signature') ?? '';
  const secret = process.env.STRALO_WEBHOOK_SIGNER ?? '';

  const valid = await verifyWebhookSignature(rawBody, signatureHeader, secret);
  if (!valid) {
    return new Response('invalid or stale webhook signature', { status: 401 });
  }

  const deliveryId = request.headers.get('X-Polsa-Delivery-Id');
  if (!deliveryId) {
    return new Response('missing delivery id', { status: 400 });
  }
  if (processedDeliveryIds.has(deliveryId)) {
    return new Response('webhook already processed', { status: 409 });
  }
  processedDeliveryIds.add(deliveryId);

  try {
    const event = JSON.parse(rawBody) as unknown;
    await processEvent(event);
    return new Response(null, { status: 204 });
  } catch {
    processedDeliveryIds.delete(deliveryId);
    return new Response('invalid webhook payload', { status: 400 });
  }
}

Invalid or stale signatures return 401. The in-memory Set is only a runnable demo: production receivers must replace it with a durable, atomic processed-ID claim, such as a unique insert that rejects an existing X-Polsa-Delivery-Id.

Replay protection

The five-minute freshness check limits how long a captured signature remains acceptable, but it does not deduplicate a valid delivery sent twice. After signature verification and before JSON.parse or event processing, atomically claim X-Polsa-Delivery-Id in a durable store. If the claim already exists, return a non-2xx duplicate response and skip processing.

Keep X-Polsa-Event-Type as metadata only. The signature authenticates the exact body and embedded timestamp; event type is not a substitute for verification.

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, booking.reminder, proposal.accepted, and proposal.rejected — 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. The per-event shapes and verification recipe are drafted on Webhook event shapes below.

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.