SemiLayerDocs

REST API Reference

The SemiLayer REST API is available at https://api.semilayer.com. All endpoints accept and return JSON. Authentication is via API key in the Authorization header.

Authentication

Authorization: Bearer {apiKey}

Use sk_ keys for server-side calls. Use pk_ keys for client-side calls — they enforce Lens access rules.

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

X-User-Token: {userJwt}

Base URL

https://api.semilayer.com

Search the vector index for a given query. Does not hit your source database.

POST /v1/search/:lens

Request Body

FieldTypeRequiredDescription
querystringSearch query
limitnumberMax results. Default: 10
minScorenumberMinimum similarity score (0-1). Default: 0
modestringsemantic | keyword | hybrid. Default: lens config then semantic

Response

{
  "results": [
    {
      "id": "emb_abc123",
      "sourceRowId": "42",
      "content": "Lightweight running shoe with responsive foam ...",
      "metadata": { "id": 42, "name": "Air Glide 3", "category": "footwear", "price": 89.99 },
      "score": 0.94
    }
  ],
  "meta": {
    "lens": "products",
    "query": "lightweight running shoes",
    "mode": "semantic",
    "count": 10,
    "durationMs": 18
  }
}

Example

curl -X POST https://api.semilayer.com/v1/search/products \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"query":"lightweight running shoes","limit":10,"mode":"hybrid"}'

Similar

Find records similar to a given record using its stored vector.

POST /v1/similar/:lens

Request Body

FieldTypeRequiredDescription
idstringSource record primary key (as string)
limitnumberMax results. Default: 10
minScorenumberMinimum similarity score. Default: 0

Response

Same shape as Search: { results: SearchResult[], meta: {...} }

Example

curl -X POST https://api.semilayer.com/v1/similar/products \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"id":"42","limit":5}'

Query

Read directly from your source database through the Bridge.

⚠️

Must be explicitly enabled in the Lens config with rules.query. Returns 403 Forbidden if not enabled.

POST /v1/query/:lens

Request Body

FieldTypeRequiredDescription
whereobjectEquality filters. Keys are output field names
orderByobject | array{ field, dir? } or array of same
limitnumberDefault: 100
offsetnumberRow offset
cursorstringOpaque pagination cursor from previous response
selectstring[]Field names to include in response

Response

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

Example

curl -X POST https://api.semilayer.com/v1/query/products \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "where": { "category": "footwear" },
    "orderBy": { "field": "price", "dir": "asc" },
    "limit": 20
  }'

Paginate with cursor:

curl -X POST https://api.semilayer.com/v1/query/products \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "where": { "category": "footwear" },
    "orderBy": { "field": "price", "dir": "asc" },
    "limit": 20,
    "cursor": "eyJpZCI6NTB9"
  }'

Ingest Webhook

Trigger an ingest job from your application. Use an ik_ key.

POST /v1/ingest/:lens

Request Body

FieldTypeRequiredDescription
modestringincremental | full | records

Mode incremental — time-windowed sync using changeTrackingColumn:

{ "mode": "incremental" }

Mode full — re-index all records from scratch:

{ "mode": "full" }

Mode records — sync specific records:

{
  "mode": "records",
  "changes": [
    { "action": "upsert", "id": "42" },
    { "action": "delete", "id": "17" }
  ]
}

Response

{
  "jobId": "job_abc123",
  "status": "queued",
  "lens": "products",
  "mode": "incremental"
}

Example

curl -X POST https://api.semilayer.com/v1/ingest/products \
  -H "Authorization: Bearer ik_live_..." \
  -H "Content-Type: application/json" \
  -d '{"mode":"incremental"}'

Count

Count rows on a lens matching a where predicate. Sibling primitive of query — same RBAC, same access rules, same operator vocabulary, but returns a scalar instead of paying for the rows themselves. Bridges with native count push it down (SELECT count(*)); streaming-only bridges run the same reduce in the bridge process.

POST /v1/lens/:lens/count

Request Body

FieldTypeRequiredDescription
whereWhereClauseSame operator grammar as query. Supports $eq, $in, $gt(e), $lt(e), $ne, $ilike, $like, $exists, $or, $and, $not.

Response

{
  "count": 1842,
  "meta": {
    "lens": "orders",
    "strategy": "native",
    "durationMs": 6
  }
}

meta.strategy is "native" when the bridge supports count(*) directly, or "streaming" when the count was reduced from a row scan. Some bridges return an approximate count; in that case meta.approximate is true.

Example

curl -X POST https://api.semilayer.com/v1/lens/orders/count \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"where":{"$or":[{"status":"failed"},{"total_cents":{"$gte":50000}}]}}'

grants.query gates count for pk_ keys (count is a query-class read). sk_ keys bypass.

Analyze

The analyze facet exposes five HTTP routes per lens. Each named analysis is reachable as analyze/:lens/:name.

List analyses

GET /v1/analyze/:lens

Returns metadata for every named analysis declared on the lens.

{
  "analyses": [
    {
      "name": "byCategory",
      "kind": "metric",
      "dimensions": [{ "field": "category" }],
      "measures": { "total": { "agg": "count" }, "avgPrice": { "agg": "avg", "column": "price" } }
    },
    { "name": "signupFunnel", "kind": "funnel" }
  ]
}

Run an analysis

POST /v1/analyze/:lens/:name

Request body

FieldTypeDescription
whereWhereClausePer-call narrowing on top of the spec's candidates.where.
cursorstringPage cursor when the bucket count exceeded limit.
cache'no-cache' | 'force'Override cache directive.

Body cap is 8 KB. Larger bodies return 400 analyze_input_too_large.

Response

{
  "kind": "metric",
  "buckets": [
    {
      "dims":     { "category": "footwear" },
      "measures": { "total": 412, "avgPrice": 79.34 },
      "count":    412,
      "bucketKey": "eyJsZW5zSWQ..."
    }
  ],
  "totals": { "total": 1842 },
  "meta": {
    "strategy":        "pushdown",
    "bridgesInvolved": ["@semilayer/bridge-postgres"],
    "estimatedCost":   { "rowsScanned": 1842 },
    "durationMs":      18,
    "cached":          false,
    "approximate":     false
  }
}

Every successful response sets:

  • X-SemiLayer-Cache: hit | miss | bypass — cache layer status
  • X-SemiLayer-Candidates-Clamped: <N> — tier-resolved candidate ceiling

Drill-down rows

POST /v1/analyze/:lens/:name/rows

The full body / response shape lives in the drill-down docs. Quick reference:

FieldTypeRequiredDescription
bucketKeystringSigned token from a bucket on the parent analyze. 24h TTL.
cursorstringPagination cursor returned by the previous response.
limitnumberDefault 100, max 1000.
searchstringFree-text query inside the bucket. Empty = no filter.
searchMode'auto' | 'simple' | 'semantic' | 'hybrid'Routing override. Default 'auto'.
orderByRowOrderByOne or many { field, dir? }. PK is appended as a stable tiebreaker.

Response shape: { rows, cursor?, total?, crossSource?, meta?: { searchMode, orderBy } }.

Streaming export — drill bucket

POST /v1/analyze/:lens/:name/rows/export

Same body as /rows minus cursor (server walks it internally), plus format: 'ndjson' | 'csv' (default 'ndjson'). Response is Content-Type: application/x-ndjson or text/csv; charset=utf-8, chunked, with Content-Disposition: attachment; filename="<lens>.<analysis>.export.<format>".

Tier-aware row caps (Free 10k / Pro 100k / Team 1M / Scale 10M / Enterprise unlimited). When the cap halts the stream, the response sets HTTP trailers:

X-SemiLayer-Export-Truncated: true
X-SemiLayer-Export-Actual-Rows: 100000
X-SemiLayer-Export-Max-Rows:    100000

See Exports for the full streaming contract.

Streaming export — query rows

POST /v1/query/:lens/export

Same streaming contract as the drill export, with the lens-wide query body shape:

FieldTypeDescription
whereWhereClausePredicate. Same grammar as query.
orderByRowOrderBySort.
selectstring[]Field projection — also seeds the CSV column order.
format'ndjson' | 'csv'Default 'ndjson'.

Tier cap column is query_export_rows_max (independent from the analyze export cap, same ladder by default).

Explain (sk-only)

POST /v1/analyze/:lens/:name/explain

Same body as the run route. Service keys onlypk_ returns 403 forbidden. Response carries the full plan, bridges-involved, sketch choices, expected accuracy, expected cost, plus the cache reason and the limits envelope (resolved tier ceilings).

Index advisor (sk-only)

GET /v1/analyze/:lens/:name/plan

Returns DDL recommendations the customer can paste into the source DB to make this analyze faster. Pure analysis — no side effects. Dialects: pg, mysql, sqlite, clickhouse, mongo. The CLI surface is semilayer analyze plan.

Errors

StatusCodeWhen
400analyze_input_too_largeBody > 8 KB.
400bucket_key_requiredDrill / export body missing bucketKey.
400bucket_key_invalidSignature failed or token expired (24h).
400unknown_fieldorderBy.field isn't on the lens.
400unsupported_search_modesearchMode: 'simple' against a lens with no text fields.
403forbiddengrants.analyze.<name> denied, or explain/plan called with a pk_ key.
404lens_not_found / analysis_not_foundResource doesn't exist on this env.
413scan_budget_exceededStreaming/hybrid scan exceeded analyzeCandidatesMax.
413parent_set_too_largeCross-source parent set exceeded analyzeParentSetMax.
413exact_reducer_budget_exceededExact percentile / count_distinct buffered more than analyzeExactValuesMax.
502(bridge-classified)Bridge error. Body carries code / message / detail.

Feed

The feed facet exposes three HTTP routes per lens. Each named feed on the lens is reachable as feed/:lens/:name.

List feeds

GET /v1/feed/:lens

Returns metadata for every named feed declared on the lens.

{
  "lens": "posts",
  "feeds": [
    { "name": "discover", "rule": "public", "pageSize": 12, "candidatesFrom": "embeddings", "scorers": ["similarity", "recency", "engagement"] },
    { "name": "latest",   "rule": "public", "pageSize": 20, "candidatesFrom": "recent",     "scorers": ["recency"] }
  ]
}

Fetch a page

POST /v1/feed/:lens/:name

Request body

FieldTypeDescription
contextRecord<string, unknown>Free-form context (cap 8KB). Lens config declares which paths to read.
cursorstringOpaque cursor from the previous page. Omit for first page.
pageSizenumberOverride the configured pageSize (max 100).

Response

{
  "items": [
    {
      "id": "emb_abc",
      "sourceRowId": "42",
      "content": "...",
      "metadata": { "title": "...", "author": "..." },
      "score": 0.86,
      "rank": 1
    }
  ],
  "cursor": "eyJ2IjoxLCJzZWVuIjpbIjQyIl0sImN0eCI6IjFmM2MyZDQyIiwidHMiOjE3NjEyMzQ1Njc4OTB9.HMACSIG",
  "evolved": false,
  "meta": {
    "lens": "posts",
    "name": "discover",
    "pageSize": 10,
    "count": 1,
    "durationMs": 42
  }
}

cursor: null signals there are no more pages. evolved: true means the supplied context's hash differs from the cursor's previous hash — UI cue that ranking shifted.

Errors

StatusCodeWhen
400feed_context_too_largeContext payload > 8 KB. Response includes actualBytes, maxBytes, hint. See Signals for the three patterns.
400invalid_cursorCursor signature failed or expired (>1h).
400seed_record_id_missingrecordVector mode but context lacks the configured path.
400seed_record_not_foundrecordVector seed id not in the lens.
400context_embed_failedEmbedding provider returned an error embedding the context text.
403forbiddenAccess rule (rules.feed.<name>) denied.
404not_foundLens not found.
404feed_not_foundFeed name not declared on this lens.
409lens_error_stateLens is in error state and not queryable.
502seed_lookup_failed / candidate_fetch_failedBridge / vector store error.

Per-record explain (sk-only)

POST /v1/feed/:lens/:name/explain

Service keys only — pk_ keys get 403 forbidden.

Request body

FieldTypeDescription
recordIdstringSource row id of the record to explain (required).
contextRecord<string, unknown>Same context shape as fetch — same scoring.

Response

{
  "recordId": "42",
  "finalScore": 0.86,
  "rank": 2,
  "contributions": [
    { "scorer": "similarity", "weight": 0.6, "rawValue": 0.5, "weightedValue": 0.30 },
    { "scorer": "recency",    "weight": 0.4, "rawValue": 1.0, "weightedValue": 0.40 }
  ],
  "metadata": { ... }
}

See Explain for use cases.

Error Responses

All errors return a JSON body with an error field:

{ "error": "Access denied by lens rules" }
StatusWhen
400Invalid request body
401Missing or invalid API key
403Key does not have permission for this operation
404Lens not found
429Rate limit exceeded (SaaS only) — Retry-After header included. SMART_SYNC_QUOTA_EXHAUSTED body for manual /sync routes when the monthly smart_sync_jobs cap is reached (manual button + CLI + scheduled runs share the meter).
502Embedding provider error

Streaming (WebSocket)

See HTTP & WebSocket for the full WebSocket protocol reference, including connection setup, op frames, event frames, heartbeat handling, and close codes.

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