~/bayar.dev/docs
API & Architecture Reference
The streaming endpoint behind the terminal on this site — I built it, and this is exactly how it works. Request schema, SSE event protocol, headers, limits, and the error contract, followed by the reference architecture for where I'm taking it: edge caching and private-cloud deployment.
Last updated: October 25, 2026. API version v1.
Overview
The public AI endpoint is POST /api/chat. It is stateless: the client holds the conversation and replays the full history on every turn, and the server keeps nothing between requests. Responses are streamed token by token over server-sent events.
Everything under Endpoint through Errors describes behaviour that is live right now and can be verified with curl. The two architecture sections at the end are explicitly labelled where they describe a target design rather than a deployed system.
Endpoint
| Property | Value |
|---|---|
| Method | POST |
| Path | /api/chat |
| Content type | application/json |
| Response | text/event-stream |
| Auth | None on the public demo endpoint — rate limited by IP |
| Max duration | 30 seconds per request |
| Idempotency | None — every request is a fresh inference call |
Try It
Send a real request from this page. The console shows the streamed reply, status, the headers that matter, and every event of the stream as it arrives — the same response curl -N would print.
Sends a real request and shows the reply, headers and raw events. It counts toward your limit of 10 a minute and 100 a day.
GET /api/status
Returns the current Worker version, upload timestamp and deployed Git commit as JSON. Cached for up to 60 seconds; it does not call the model or consume the chat budget.
curl --include https://bayar.dev/api/statusHeader Specification
| Header | Requirement |
|---|---|
| Content-Type | Required. Must be application/json; any other body encoding fails JSON parsing and returns 400. |
| Accept | Optional. text/event-stream is recommended for streaming clients; the response is an event stream regardless. |
| Authorization | Not used by the public endpoint. Private deployments enforce Bearer <token> at the gateway — see VPC & BYOK. Sending it against the public endpoint is ignored, never logged, and never forwarded upstream. |
| CF-Connecting-IP | Set by Cloudflare, not by callers. This is the rate-limit identity. X-Real-IP is the fallback, then the first entry of X-Forwarded-For. |
On bearer tokens
Request Body
A single messages array, ordered oldest to newest. The server derives the model context from it and discards it after the response completes.
POST /api/chat HTTP/1.1
Host: bayar.dev
Content-Type: application/json
{
"messages": [
{
"id": "msg_1",
"role": "user",
"parts": [{ "type": "text", "text": "benchmark --latency" }]
},
{
"id": "msg_2",
"role": "assistant",
"parts": [{ "type": "text", "text": "No latency figures have been measured yet." }]
},
{
"id": "msg_3",
"role": "user",
"parts": [{ "type": "text", "text": "architecture --stack" }]
}
]
}type ChatRequest = {
messages: {
// Optional client-side id, echoed nowhere. Max 128 chars.
id?: string;
// 'system' is rejected: instructions cannot be injected by a client.
role: 'user' | 'assistant';
// 1-32 parts per message. Parts whose type is not 'text' are
// discarded server-side rather than rejected, so SDK-emitted
// parts such as 'step-start' are safe to send back verbatim.
parts: {
type: string; // max 64 chars
text?: string; // max 4,000 chars
}[];
}[]; // 1-100 messages per request
};Unknown top-level keys are stripped rather than rejected. Parts with a type other than text are dropped before the request reaches the model, which is what makes it safe to echo an SDK-generated assistant message straight back.
curl -N --include -X POST https://bayar.dev/api/chat \
-H 'Content-Type: application/json' \
-d '{
"messages": [
{
"role": "user",
"parts": [{ "type": "text", "text": "architecture --stack" }]
}
]
}'Limits
Two different mechanisms apply. Payload caps reject abuse outright. Context budgets trim a long-but-legitimate conversation instead of failing it, so a session that runs long loses its oldest turns rather than breaking.
| Limit | Value | Behaviour past the limit |
|---|---|---|
| messages per request | 100 | Rejected — 400 |
| parts per message | 32 | Rejected — 400 |
| chars per part | 4,000 | Rejected — 400 |
| chars per request | 50,000 | Rejected — 400 |
| context messages | 20 | Trimmed to the most recent 20 |
| context chars | 12,000 | Oldest turns dropped until it fits |
| requests per minute | 10 per IP | Rejected — 429 |
| requests per day | 100 per IP | Rejected — 429 |
A 429 carries Retry-After in seconds alongside X-RateLimit-Limit and X-RateLimit-Remaining. The per-minute window is Cloudflare's rate-limiting binding, which only answers allow or deny. A minute-window 429 therefore reports Retry-After: 60 and X-RateLimit-Remaining: 0, not the time left in the minute or the live counter. The daily window is one conditional write in D1, so concurrent requests cannot both take the last slot. A daily 429 reports remaining as 0 and Retry-After as the seconds until that window ends. Both windows are consumed on every request, so tripping the per-minute limit does not shield the daily budget. The limit check runs before the body is read, which keeps a flood cheap to reject.
SSE Streaming Schema
The response is a UI message stream: one JSON object per data: line, terminated by data: [DONE]. Each object has a type discriminant.
| Event type | Payload | Meaning |
|---|---|---|
| start | — | Response opened |
| start-step | — | Model step began |
| text-start | id | A text part opened; id identifies it |
| text-delta | id, delta | Append delta to the part with that id |
| text-end | id | That text part is complete |
| finish-step | — | Model step complete |
| finish | finishReason | Response complete, e.g. "stop" |
| error | errorText | Failure raised mid-stream after 200 OK |
| [DONE] | — | Stream terminator — not JSON |
HTTP/1.1 200 OK
content-type: text/event-stream
cache-control: no-cache
x-accel-buffering: no
x-vercel-ai-ui-message-stream: v1
transfer-encoding: chunked
data: {"type":"start"}
data: {"type":"start-step"}
data: {"type":"text-start","id":"msg_0b19..."}
data: {"type":"text-delta","id":"msg_0b19...","delta":"No"}
data: {"type":"text-delta","id":"msg_0b19...","delta":" figures"}
data: {"type":"text-end","id":"msg_0b19..."}
data: {"type":"finish-step"}
data: {"type":"finish","finishReason":"stop"}
data: [DONE]text-delta events are additive: concatenate deltas in arrival order, keyed by id. A stream may contain more than one text part, so do not assume a single buffer. x-accel-buffering: no is set to stop intermediate proxies from buffering the stream into one chunk.
Client Usage
The reference client is the AI SDK's useChat, which handles history accumulation, incremental parsing, and cancellation. This is what the terminal on the home page runs.
'use client';
import { useChat } from '@ai-sdk/react';
export function Terminal() {
// Defaults to POST /api/chat and accumulates the full history client-side.
const { messages, sendMessage, status, error, stop } = useChat();
const streaming = status === 'streaming' || status === 'submitted';
return (
<>
{messages.map((m) => (
<p key={m.id}>
{m.parts
.filter((p) => p.type === 'text')
.map((p) => p.text)
.join('')}
</p>
))}
<button onClick={() => sendMessage({ text: 'benchmark --latency' })}>
run
</button>
{/* Aborts the fetch and closes the stream mid-response. */}
{streaming && <button onClick={stop}>stop</button>}
{/* 4xx bodies arrive as JSON in error.message. */}
{error && <p role="alert">{error.message}</p>}
</>
);
}Error Contract
Errors are returned as JSON with an error string. Messages are deliberately generic: upstream provider identity, hostnames, and stack traces are logged server-side and never serialised to the client.
Each error comes from one step of the request path, in the order the route runs them. Anything after headers are sent can only arrive as an in-stream error event.
1Rate limit
Checked before the body is read. 10 a minute, 100 a day, per IP.
- 429Rate limit exceeded. Try again in Ns.
- 500Internal Server Error (limiter unavailable)
2Validate
JSON parse, schema, role allowlist and size caps.
- 400Malformed JSON body.
- 400Invalid request body.
- 400No text content in request.
- 400Conversation too long.
3Model
Workers AI call, capped at the request timeout.
- 500Internal Server Error
4Stream back
Headers are already sent, so failures arrive inside the stream.
- 200 + error eventUpstream model request failed.
Handling the split failure model
200 OK. Clients must handle both a non-2xx JSON response and an error event mid-stream — treating only the status code as the success signal will silently swallow upstream failures.Live Architecture
What actually serves this site today. Every stage below is in the request path of the terminal above.
browser (React client, useChat)
|
| POST /api/chat · full message history · application/json
v
Next.js route handler (App Router · 30s model timeout)
|
+-- 1. rate limit 10/min on the edge limiter · 100/day in D1
| checked BEFORE the body is read
+-- 2. validate schema check · role allowlist · per-part size caps
+-- 3. normalise non-text parts dropped
| context trimmed to 20 messages / 12,000 chars
+-- 4. stream Cloudflare Workers AI · native AI binding
|
v
SSE response to browser
upstream detail is logged server-side and masked on the wire,
so provider hostnames and stack traces never reach the client.The per-minute counter is Cloudflare's rate-limiting binding, shared across isolates. The daily counter is a D1 row updated by one conditional write. If that database cannot be reached, the daily window falls back to process memory for that request. In-memory counters are per-isolate and reset when the isolate does, so they are a fallback, not the limit that bounds a day of traffic.
Edge Caching
Reference architecture
+--------------------------------------+
request ----> edge PoP | 1. static assets (immutable, CDN) |
| 2. semantic cache (normalised key) |
+------------------+-------------------+
hit | | miss
v v
replayed stream origin route handler
|
v
model provider
|
write-through <-------+
key = hash(tenant, model, normalised prompt embedding bucket)
scope = per-tenant namespace; never shared across tenants
ttl = short by default; invalidated on system-prompt change
bypass = requests marked non-deterministic, or tenant opt-outTwo tiers with different correctness requirements. Static assets are content-hashed and cached immutably at the PoP. Inference responses use a semantic cache keyed on a normalised prompt within a per-tenant namespace — never a global one, since cross-tenant reuse would leak prompt content between customers.
Cache entries are scoped to the model and the system prompt, so a prompt change invalidates rather than silently serving stale answers. Requests marked non-deterministic bypass the cache entirely, and tenants can disable it per environment.
Private VPC & BYOK Deployment
Reference architecture
customer cloud account (Azure or AWS)
|
+-- private subnet
| +-- bayar.dev gateway container image, customer-operated
| +-- private endpoint ----> customer's own model deployment
|
+-- Key Vault / KMS customer-managed keys (BYOK)
| +-- encryption at rest and in transit under customer keys
| +-- key rotation and revocation stay with the customer
|
+-- observability logs and metrics stay in-account
|
+-- egress no data path to a bayar.dev control
plane; licence and image pulls onlyThe gateway runs as a container inside the customer's own account and subnet, reaching the model deployment over a private endpoint rather than the public internet. Encryption keys stay in the customer's Key Vault or KMS, so rotation and revocation are customer-controlled — that is the substance of BYOK, as opposed to a provider-held key labelled as one.
In this topology, prompt and completion data never crosses into infrastructure operated by bayar.dev. Tenant authentication is enforced at the gateway with the bearer token described under Headers. Data handling for the public endpoint is documented separately in the privacy policy.
Other routes
GET /api/founders-access/claim is the checkout return route for founders access, not a general API. It verifies a completed Stripe session and sets an access cookie. Use the checkout return link; do not put session IDs in examples or logs.