SemiLayerDocs

HTTP & WebSocket

This page is for developers building SemiLayer clients in languages or environments where @semilayer/client is not available — Go, Python, Ruby, Rust, browser vanilla JS, edge runtimes, and anything else that can speak HTTP and WebSocket.

The protocol is simple. If you can make an HTTP POST and a WebSocket connection, you can implement the full SemiLayer API surface.


HTTP API

Base URL

https://api.semilayer.com

Authentication

Pass your API key as a Bearer token:

Authorization: Bearer sk_live_...

For per-user access control, also pass the user JWT:

X-User-Token: eyJhbGci...
POST /v1/search/:lens
Content-Type: application/json
Authorization: Bearer {apiKey}
{
  "query": "lightweight running shoes",
  "limit": 10,
  "minScore": 0.5,
  "mode": "semantic"
}

Response:

{
  "results": [
    {
      "id": "emb_abc",
      "sourceRowId": "42",
      "content": "...",
      "metadata": { "name": "Air Glide 3", "price": 89.99 },
      "score": 0.94
    }
  ],
  "meta": { "lens": "products", "query": "...", "count": 10, "durationMs": 18 }
}

Similar

POST /v1/similar/:lens
Content-Type: application/json
Authorization: Bearer {apiKey}
{ "id": "42", "limit": 5, "minScore": 0.7 }

Response: same shape as search.

Query

POST /v1/query/:lens
Content-Type: application/json
Authorization: Bearer {apiKey}
{
  "where": { "category": "footwear" },
  "orderBy": { "field": "price", "dir": "asc" },
  "limit": 20,
  "cursor": "eyJpZCI6NTB9"
}

Response:

{
  "rows": [ { "id": 42, "name": "Air Glide 3", "price": 89.99 } ],
  "meta": { "lens": "products", "total": 142, "nextCursor": "...", "count": 20, "durationMs": 12 }
}

Error Responses

{ "error": "Access denied by lens rules" }
StatusMeaning
400Bad request body
401Invalid API key
403Operation not permitted by access rules
404Lens not found
429Rate limit — check Retry-After header
502Upstream (embedding) error

WebSocket Protocol

The streaming endpoint uses a single multiplexed WebSocket connection. You can run multiple operations (search, subscribe, observe) over one socket using the id field to correlate frames.

Connection

GET wss://api.semilayer.com/v1/stream/:lens
  ?key={apiKey}
  &userToken={userJwt}    ← optional

The lens name is in the path. The API key is in the query string.

ℹ️

TLS is required (wss://). Plaintext WebSocket (ws://) is only available in self-hosted deployments on localhost.

Connection Lifecycle

  1. Client opens WebSocket connection
  2. Server authenticates the API key during the HTTP upgrade
  3. On auth failure: server closes with code 4401 before the connection upgrades
  4. On success: connection is open, client can send op frames

Op Frames (Client → Server)

All frames are JSON-encoded text messages.

Op frame:

{
  "id": "op_1",
  "op": "search.stream",
  "params": { "query": "running shoes", "limit": 50 }
}
FieldTypeDescription
idstringUnique ID for this op — correlates with response frames
opstringOne of the op types below
paramsobjectOp-specific parameters

Op types:

opDescriptionRelevant params
search.streamChunked semantic searchSearchParams
query.streamChunked direct queryQueryParams
subscribeLive tail — all changes{ filter?: object }
observeWatch one record{ recordId: string }
unsubscribeCancel a running op(op frame with no params)

Heartbeat pong:

{ "type": "pong" }

Must be sent in response to every ping frame within 30 seconds, or the server closes the connection.

Response Frames (Server → Client)

Row frame — one result from search.stream or query.stream:

{
  "id": "op_1",
  "type": "row",
  "data": {
    "id": "emb_abc",
    "sourceRowId": "42",
    "content": "...",
    "metadata": { "name": "Air Glide 3", "price": 89.99 },
    "score": 0.94
  }
}

Done frame — signals the end of a chunked op:

{
  "id": "op_1",
  "type": "done",
  "meta": { "count": 50, "durationMs": 42 }
}

Event frame — one change from subscribe or observe:

{
  "id": "op_2",
  "type": "event",
  "kind": "insert",
  "record": { "id": 99, "name": "New Product", "price": 29.99 }
}

kind is "insert" | "update" | "delete".

Error frame — operation-level or connection-level error:

{
  "id": "op_1",
  "type": "error",
  "code": "rate_limited",
  "message": "WebSocket row budget exceeded"
}

If id is absent, the error applies to the whole connection.

codeMeaning
rate_limitedRow/connection budget exceeded
forbiddenLens rules denied the op
bad_requestInvalid op params
not_foundLens not found
internalServer-side error

Ping frame — heartbeat from server:

{ "type": "ping" }

Respond with { "type": "pong" }.

Close Codes

CodeMeaning
1000Normal close
1011Server-side internal error
4401Auth failed during upgrade
4403Lens access forbidden
4290Over concurrent-connection quota
4291Row budget exceeded mid-stream

Minimal TypeScript Client (annotated)

A complete, working client you can adapt or use as a reference:

const BASE_URL = 'https://api.semilayer.com'
const WS_URL  = 'wss://api.semilayer.com'

// ── HTTP helpers ────────────────────────────────────────────────

async function apiPost<T>(
  apiKey: string,
  path: string,
  body: unknown,
  userToken?: string,
): Promise<T> {
  const headers: Record<string, string> = {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${apiKey}`,
  }
  if (userToken) headers['X-User-Token'] = userToken

  const res = await fetch(`${BASE_URL}${path}`, {
    method: 'POST',
    headers,
    body: JSON.stringify(body),
  })

  if (!res.ok) {
    const err = await res.json().catch(() => ({ error: res.statusText }))
    throw new Error(`${res.status}: ${err.error}`)
  }
  return res.json() as Promise<T>
}

export const search = (apiKey: string, lens: string, params: object) =>
  apiPost(apiKey, `/v1/search/${lens}`, params)

export const similar = (apiKey: string, lens: string, params: object) =>
  apiPost(apiKey, `/v1/similar/${lens}`, params)

export const query = (apiKey: string, lens: string, params: object = {}) =>
  apiPost(apiKey, `/v1/query/${lens}`, params)

// ── WebSocket streaming ─────────────────────────────────────────

export function streamSearch(
  apiKey: string,
  lens: string,
  params: object,
  onResult: (data: unknown) => void,
  onDone: (meta: unknown) => void,
  onError?: (code: string, message: string) => void,
): () => void {  // returns a cleanup function

  const url = `${WS_URL}/v1/stream/${lens}?key=${encodeURIComponent(apiKey)}`
  const ws = new WebSocket(url)
  const opId = `op_${Date.now()}`

  ws.addEventListener('open', () => {
    ws.send(JSON.stringify({ id: opId, op: 'search.stream', params }))
  })

  ws.addEventListener('message', (e) => {
    const frame = JSON.parse(e.data as string)

    if (frame.type === 'ping') {
      ws.send(JSON.stringify({ type: 'pong' }))
      return
    }
    if (frame.type === 'row'  && frame.id === opId) onResult(frame.data)
    if (frame.type === 'done' && frame.id === opId) { onDone(frame.meta); ws.close() }
    if (frame.type === 'error') {
      onError?.(frame.code, frame.message)
      ws.close()
    }
  })

  return () => ws.close()
}

// ── Live tail ───────────────────────────────────────────────────

export function subscribe(
  apiKey: string,
  lens: string,
  onEvent: (kind: string, record: unknown) => void,
): () => void {

  const url = `${WS_URL}/v1/stream/${lens}?key=${encodeURIComponent(apiKey)}`
  const ws = new WebSocket(url)
  const opId = `op_${Date.now()}`

  ws.addEventListener('open', () => {
    ws.send(JSON.stringify({ id: opId, op: 'subscribe', params: {} }))
  })

  ws.addEventListener('message', (e) => {
    const frame = JSON.parse(e.data as string)
    if (frame.type === 'ping')  { ws.send(JSON.stringify({ type: 'pong' })); return }
    if (frame.type === 'event') onEvent(frame.kind, frame.record)
  })

  return () => ws.close()
}

Python example (httpx)

import httpx
import json

BASE_URL = "https://api.semilayer.com"

def search(api_key: str, lens: str, query: str, limit: int = 10):
    response = httpx.post(
        f"{BASE_URL}/v1/search/{lens}",
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
        },
        json={"query": query, "limit": limit},
    )
    response.raise_for_status()
    return response.json()

results = search("sk_live_...", "products", "eco-friendly water bottle")
for r in results["results"]:
    print(r["metadata"]["name"], r["score"])

Go example (net/http)

package semilayer

import (
    "bytes"
    "encoding/json"
    "fmt"
    "net/http"
)

const BaseURL = "https://api.semilayer.com"

type SearchResult struct {
    ID          string                 `json:"id"`
    SourceRowID string                 `json:"sourceRowId"`
    Metadata    map[string]interface{} `json:"metadata"`
    Score       float64                `json:"score"`
}

type SearchResponse struct {
    Results []SearchResult `json:"results"`
}

func Search(apiKey, lens, query string, limit int) (*SearchResponse, error) {
    body, _ := json.Marshal(map[string]interface{}{
        "query": query,
        "limit": limit,
    })

    req, _ := http.NewRequest("POST",
        fmt.Sprintf("%s/v1/search/%s", BaseURL, lens),
        bytes.NewReader(body),
    )
    req.Header.Set("Authorization", "Bearer "+apiKey)
    req.Header.Set("Content-Type", "application/json")

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    var result SearchResponse
    if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
        return nil, err
    }
    return &result, nil
}