~/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

PropertyValue
MethodPOST
Path/api/chat
Content typeapplication/json
Responsetext/event-stream
AuthNone on the public demo endpoint — rate limited by IP
Max duration30 seconds per request
IdempotencyNone — 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.

POST /api/chat

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/status

Header Specification

HeaderRequirement
Content-TypeRequired. Must be application/json; any other body encoding fails JSON parsing and returns 400.
AcceptOptional. text/event-stream is recommended for streaming clients; the response is an event stream regardless.
AuthorizationNot 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-IPSet 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

The public demo endpoint is intentionally unauthenticated so the terminal works with no signup. It is protected by request validation and per-IP rate limits instead. Token-based tenant auth is part of the private deployment path, not of this endpoint — treat any bearer token you send here as ignored.

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
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" }]
    }
  ]
}
TypeScript — request schema
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.

LimitValueBehaviour past the limit
messages per request100Rejected — 400
parts per message32Rejected — 400
chars per part4,000Rejected — 400
chars per request50,000Rejected — 400
context messages20Trimmed to the most recent 20
context chars12,000Oldest turns dropped until it fits
requests per minute10 per IPRejected — 429
requests per day100 per IPRejected — 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 typePayloadMeaning
start—Response opened
start-step—Model step began
text-startidA text part opened; id identifies it
text-deltaid, deltaAppend delta to the part with that id
text-endidThat text part is complete
finish-step—Model step complete
finishfinishReasonResponse complete, e.g. "stop"
errorerrorTextFailure raised mid-stream after 200 OK
[DONE]—Stream terminator — not JSON
Response — captured stream
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.

React — minimal client
'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.

  1. 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)
  2. 2Validate

    JSON parse, schema, role allowlist and size caps.

    • 400Malformed JSON body.
    • 400Invalid request body.
    • 400No text content in request.
    • 400Conversation too long.
  3. 3Model

    Workers AI call, capped at the request timeout.

    • 500Internal Server Error
  4. 4Stream back

    Headers are already sent, so failures arrive inside the stream.

    • 200 + error eventUpstream model request failed.

Handling the split failure model

A streaming endpoint can fail after it has already returned 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.

Deployed request path
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

The design below is the target topology for a production tenant. It is not what serves this public demo, which runs the request path in Live Architecture with no semantic cache layer.
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-out

Two 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

The planned product deployment for running the gateway in a customer's own cloud account. It is not shipped. Product questions go to hi@bayar.dev.
Private cloud — 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 only

The 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.