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 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.
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.
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.
// 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.
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.
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.
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.
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.
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());post_agentspost_bookingsget_bookingsdelete_bookings_idpost_proposalspatch_proposals_id_acceptpatch_proposals_id_rejectget_bookings_id_occurrencesUse 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.
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.
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.
{
"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
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
400invalid JSON:{ error: "bad_request", message: "Body must be valid JSON." }400schema failure:{ errors: { field: "first validation message" } }401missing or invalid credential:{ error, message }.409reusedidempotency_key:{ error: "conflict", message }.500unexpected server failure:{ error: "Internal Server Error" }.
/api/agentsBootstrap 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 oneX-API-KeyorAuthorization: Bearerheader. A repeated bootstrap returns409with 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
- 400
bad_request— Body is not valid JSON. - 400
bad_request— Zod validation failed — body is { errors: { name, metadata?, config?, idempotency_key?, bootstrap? } }. - 401
unauthorized— Missing credential (use bootstrap:true only for the first agent) or a supplied invalid credential. - 409
bootstrap_claimed— The first agent already exists; no public_token is returned. Use an existing key or the dashboard. - 409
conflict— The idempotency_key has already been used. - 500
Internal 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
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" }
}'/api/bookingsConfirm 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) orAuthorization: Bearer <YOUR_API_KEY>(retained fallback). The plaintextsk_<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
- 401
unauthorized— Missing or unparseable Authorization or X-API-Key header. - 400
Bad Request— Body failed Zod validation — { errors: { agentId?, startsAt?, endsAt? } }. endsAt must be after startsAt. - 403
forbidden— Credential resolved, but credential.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-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
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"
}'/api/bookingsList 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) orAuthorization: Bearer <YOUR_API_KEY>(retained fallback). The plaintextsk_<uuid>is emitted once by POST /api/agents.
Error codes
- 401
unauthorized— Missing or unparseable Authorization or X-API-Key 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-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
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"
/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 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) orAuthorization: Bearer <YOUR_API_KEY>(retained fallback). The plaintextsk_<uuid>is emitted once by POST /api/agents.
Error codes
- 401
unauthorized— Missing or unparseable Authorization or X-API-Key header. - 403
forbidden— Row exists but does not belong to this authenticated agent. - 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 "X-API-Key: <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 authenticated agent.
Required headers
- Use exactly one:
X-API-Key: <YOUR_API_KEY>(recommended) orAuthorization: Bearer <YOUR_API_KEY>(retained fallback). The plaintextsk_<uuid>is emitted once by POST /api/agents.
Error codes
- 401
unauthorized— Missing or unparseable Authorization or X-API-Key header. - 403
forbidden— Row exists but does not belong to this authenticated agent. - 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-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
curl https://stralo.polsia.app/api/bookings/bk_4f1a93de8c7240cda0f3b9e2/occurrences \ -H "X-API-Key: <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 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) orAuthorization: Bearer <YOUR_API_KEY>(retained fallback). The plaintextsk_<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
- 401
unauthorized— Missing or unparseable Authorization or X-API-Key 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-09-11T02:27:38.378Z",
"endsAt": "2026-09-11T03:27:38.378Z",
"status": "pending",
"createdAt": "2026-10-03T11:52:55.728Z"
}curl
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"
}'/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
- Use exactly one:
X-API-Key: <YOUR_API_KEY>(recommended) orAuthorization: Bearer <YOUR_API_KEY>(retained fallback). The plaintextsk_<uuid>is emitted once by POST /api/agents. Content-Type:application/json
Error codes
- 401
unauthorized— Missing or unparseable Authorization or X-API-Key header. - 403
forbidden— Proposal exists but the authenticated agent 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-09-11T02:27:38.378Z",
"endsAt": "2026-09-11T03:27:38.378Z",
"status": "accepted",
"createdAt": "2026-09-11T00:27:38.378Z"
}curl
curl -X PATCH https://stralo.polsia.app/api/proposals/pr_3a82c1e0bdfe4d39b58d2ac9/accept \ -H "X-API-Key: <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
- Use exactly one:
X-API-Key: <YOUR_API_KEY>(recommended) orAuthorization: Bearer <YOUR_API_KEY>(retained fallback). The plaintextsk_<uuid>is emitted once by POST /api/agents. Content-Type:application/json
Error codes
- 401
unauthorized— Missing or unparseable Authorization or X-API-Key header. - 403
forbidden— Proposal exists but the authenticated agent 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-09-11T02:27:38.378Z",
"endsAt": "2026-09-11T03:27:38.378Z",
"status": "pending",
"createdAt": "2026-09-11T00:27:38.378Z"
}curl
curl -X PATCH https://stralo.polsia.app/api/proposals/pr_3a82c1e0bdfe4d39b58d2ac9/reject \ -H "X-API-Key: <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-10-03T11:52:55.728Z"
}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 credential-derived; the body schema rejects it.
Required headers
- Use exactly one:
X-API-Key: <YOUR_API_KEY>(recommended) orAuthorization: Bearer <YOUR_API_KEY>(retained fallback). The plaintextsk_<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
- 401
unauthorized— Missing or unparseable Authorization or X-API-Key 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-10-03T11:52:55.728Z"
}curl
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"
}'/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
- Use exactly one:
X-API-Key: <YOUR_API_KEY>(recommended) orAuthorization: Bearer <YOUR_API_KEY>(retained fallback). The plaintextsk_<uuid>is emitted once by POST /api/agents.
Error codes
- 401
unauthorized— Missing or unparseable Authorization or X-API-Key header. - 404
not_found— No subscription matches {id} for this authenticated agent — 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 "X-API-Key: <YOUR_API_KEY>"
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.
{
"eventType": "proposal.accepted",
"id": "pr_3a82c1e0bdfe4d39b58d2ac9",
"agentId": "ag_5b8e3a1c9c2b4e1c8f7d6a5b",
"counterpartyId": "ag_2d7c44f1800f4bc9b370f93e",
"decision": "accepted",
"decidedAt": "2026-09-11T00:27:38.378Z"
}{
"eventType": "proposal.rejected",
"id": "pr_3a82c1e0bdfe4d39b58d2ac9",
"agentId": "ag_2d7c44f1800f4bc9b370f93e",
"counterpartyId": "ag_5b8e3a1c9c2b4e1c8f7d6a5b",
"decision": "rejected",
"decidedAt": "2026-09-11T00:27:38.378Z"
}# 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 onlyVerify 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.
- 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
webhookSignersecret toverifyWebhookSignaturefromstralo-jsv0.2.0. - 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 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.
--data-binary.: "${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.
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.
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.
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.
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.