Skip to content

Serving API

recotem serve exposes a FastAPI application over HTTP. All endpoints live under the /v1 namespace. Custom verbs follow the AIP-136 colon-verb convention — for example, /v1/recipes/{name}:recommend.

Authentication

All endpoints except GET /v1/health require the X-API-Key request header carrying a plaintext API key.

Keys are configured via RECOTEM_API_KEYS as a comma-separated list of <kid>:sha256:<hex64> entries. The server verifies the submitted plaintext against a scrypt-derived hash stored in the entry (scrypt parameters: N=2, r=8, p=1, salt=recotem.api-key.v1). Key length must be between 32 and 256 characters.

Generate a valid API key with:

bash
recotem keygen --type api

This produces a 43-character base64url string ready to use as the plaintext key. The corresponding sha256:<hex64> digest is printed for placement in RECOTEM_API_KEYS.

When RECOTEM_API_KEYS is empty and --insecure-no-auth is not set:

  • The server forces 127.0.0.1 as the bind host regardless of RECOTEM_HOST.
  • All requests are accepted without a key (the client is tagged as kid=anonymous in logs).

WARNING

Trailing or leading whitespace in the X-API-Key header is treated as part of the key and will not match. Trim values client-side before sending.

Common Headers

HeaderDirectionDescription
X-API-KeyRequestAuthentication token (plaintext). Required on all endpoints except GET /v1/health.
X-Request-IDRequest / ResponseClient-supplied request identifier. Must match ^[A-Za-z0-9_-]{1,128}$. Values that do not match, or absent values, cause the server to generate a fresh 12-hex identifier. The value actually used is echoed in the response.
X-Recotem-Model-VersionResponseThe model version hash (sha256:<64-hex>) of the recipe that served the request. Present on all recommendation responses. Mirrors the model_version field in the response body.
X-Recotem-Items-DegradedResponseSingle-recommendation endpoints only. Set to the total count of items whose metadata join produced a fallback or was dropped. Absent when the response is fully clean. Not sent on batch endpoints.

Recipe Name Format

Recipe names used as path parameters must match ^[A-Za-z0-9_-]{1,64}$. Paths with a name that does not match are rejected by the router — depending on how the URL parses, the response is either 404 Not Found or 422 Unprocessable Entity.

Endpoints

Recommendation

POST /v1/recipes/{name}:recommend

Get top-K recommendations for a single user.

Authentication: Required (X-API-Key).

Path parameter: name — recipe name matching ^[A-Za-z0-9_-]{1,64}$.

Request body (extra fields are forbidden):

FieldTypeConstraintsDefaultDescription
user_idstringrequired, 1–256 charsUser identifier as seen in training data.
limitinteger1–100010Maximum number of items to return.
exclude_itemsstring[] | nulloptional, ≤1000 itemsnullItem IDs to exclude from the result.
json
{
  "user_id": "u1",
  "limit": 10,
  "exclude_items": ["item-99"]
}

Response body (200 OK):

json
{
  "request_id": "a1b2c3d4e5f6",
  "recipe": "purchase_log",
  "model_version": "sha256:a3f2...e91d",
  "items": [
    {"item_id": "item-42", "score": 0.91, "title": "Example Item", "category": "books"},
    {"item_id": "item-17", "score": 0.84}
  ]
}

Items are ordered by descending score. The score field is always a finite number (NaN and Inf are rejected internally). Each item always contains item_id and score; additional fields are joined from the item metadata configured in the recipe's item_metadata block. Because RecommendItem permits extra fields, metadata-derived fields appear alongside item_id and score.

Status codes:

CodeConditionError code
200Success
401Missing X-API-KeyMISSING_API_KEY
401Key does not match any entryINVALID_API_KEY
404user_id was not seen during trainingUNKNOWN_USER
422Request body failed schema validationVALIDATION_ERROR
503Recipe is not loadedRECIPE_UNAVAILABLE

UNKNOWN_USER is not a server error

A 404 for an unknown user is expected for new users not seen during training. Handle it in your application layer — for example, fall back to popularity-based recommendations.

curl example:

bash
curl -s -X POST http://localhost:8080/v1/recipes/purchase_log:recommend \
  -H "X-API-Key: <plaintext>" \
  -H "Content-Type: application/json" \
  -d '{"user_id": "u1", "limit": 10}' | jq .

POST /v1/recipes/{name}:recommend-related

Get items related to one or more seed items.

Authentication: Required (X-API-Key).

Request body:

FieldTypeConstraintsDefaultDescription
seed_itemsstring[]required, 1–100 itemsItem IDs used as seeds.
limitinteger1–100010Maximum number of items to return.
exclude_itemsstring[] | nulloptionalnullItem IDs to exclude from the result.
json
{
  "seed_items": ["item-42", "item-17"],
  "limit": 10
}

Response body (200 OK): Same shape as :recommend.

Status codes:

CodeConditionError code
200Success
401Authentication failureMISSING_API_KEY / INVALID_API_KEY
404All seed items are unknown to the modelUNKNOWN_SEED_ITEMS
404Seeds are known but no candidates survive rankingNO_CANDIDATES
422Schema validation failureVALIDATION_ERROR
503Recipe is not loadedRECIPE_UNAVAILABLE

curl example:

bash
curl -s -X POST http://localhost:8080/v1/recipes/purchase_log:recommend-related \
  -H "X-API-Key: <plaintext>" \
  -H "Content-Type: application/json" \
  -d '{"seed_items": ["item-42"], "limit": 5}' | jq .

POST /v1/recipes/{name}:batch-recommend

Get recommendations for multiple users in a single request. Uses an Algolia-style batch envelope.

Authentication: Required (X-API-Key).

Request body:

FieldTypeConstraintsDefaultDescription
requestsRecommendRequest[]1–256 itemsPer-user recommendation requests. Each element has the same shape as the :recommend body.
include_metadatabooleanfalseWhen false, metadata-joined fields are omitted from items for bulk-performance reasons. Set to true to get the same item shape as the single-user endpoint.
json
{
  "requests": [
    {"user_id": "u1", "limit": 5},
    {"user_id": "u2", "limit": 5, "exclude_items": ["item-99"]}
  ],
  "include_metadata": false
}

Response body (200 OK):

json
{
  "request_id": "a1b2c3d4e5f6",
  "recipe": "purchase_log",
  "model_version": "sha256:a3f2...e91d",
  "results": [
    {
      "index": 0,
      "status": "ok",
      "items": [{"item_id": "item-42", "score": 0.91}]
    },
    {
      "index": 1,
      "status": "error",
      "error": {"code": "UNKNOWN_USER", "message": "user not seen during training"}
    }
  ]
}

results preserves the original order of requests via the index field. A failed element carries status: "error" and an error object; other elements in the same batch are still processed.

Batch-specific rules:

  • The requests array must contain 1–256 elements. Arrays outside this range return a 422 for the entire request.
  • The sum of all requests[].limit values must not exceed 5000. Elements that push the sum over the limit receive a per-element VALIDATION_ERROR result; later elements continue to be processed.
  • An individual element with a schema error does not fail the whole batch. The element receives a per-element VALIDATION_ERROR result and the overall HTTP response remains 200.
  • X-Recotem-Items-Degraded is not sent on batch responses.
  • 503 is returned only when the recipe itself is unavailable (not loaded). Per-element errors such as UNKNOWN_USER do not affect the HTTP status code.

curl example:

bash
curl -s -X POST http://localhost:8080/v1/recipes/purchase_log:batch-recommend \
  -H "X-API-Key: <plaintext>" \
  -H "Content-Type: application/json" \
  -d '{
    "requests": [
      {"user_id": "u1", "limit": 5},
      {"user_id": "u2", "limit": 5}
    ],
    "include_metadata": false
  }' | jq .

POST /v1/recipes/{name}:batch-recommend-related

Get related-item recommendations for multiple seeds in a single request.

Authentication: Required (X-API-Key).

Request body: Same envelope as :batch-recommend, with each element following the :recommend-related body shape.

json
{
  "requests": [
    {"seed_items": ["item-42"], "limit": 5},
    {"seed_items": ["item-17", "item-8"], "limit": 10}
  ],
  "include_metadata": false
}

Response body (200 OK): Same envelope as :batch-recommend.

Batch rules: Identical to :batch-recommend above.

curl example:

bash
curl -s -X POST http://localhost:8080/v1/recipes/purchase_log:batch-recommend-related \
  -H "X-API-Key: <plaintext>" \
  -H "Content-Type: application/json" \
  -d '{
    "requests": [
      {"seed_items": ["item-42"], "limit": 5}
    ]
  }' | jq .

Recipe Discovery

GET /v1/recipes

List all currently loaded recipes.

Authentication: Required (X-API-Key).

Stub entries for recipes whose artifact or YAML failed to load at startup are excluded — they appear in GET /v1/health/details instead.

Response body (200 OK):

json
{
  "recipes": [
    {
      "name": "purchase_log",
      "model_version": "sha256:a3f2...e91d",
      "loaded_at": "2026-05-21T00:00:00Z",
      "supported_verbs": [
        "recommend",
        "recommend-related",
        "batch-recommend",
        "batch-recommend-related"
      ],
      "kind": "user-item"
    }
  ]
}
FieldTypeDescription
namestringRecipe name (stem of the recipe YAML file).
model_versionstringsha256:<64-hex> digest of the artifact.
loaded_atstring (ISO 8601)Timestamp when the artifact was loaded into memory.
supported_verbsstring[]Colon-verbs this recipe supports. Depends on the recipe kind.
kind"user-item" | "item-item"Whether the model produces user-to-item or item-to-item recommendations. "item-item" recipes do not support recommend or batch-recommend.

curl example:

bash
curl -s http://localhost:8080/v1/recipes \
  -H "X-API-Key: <plaintext>" | jq .

GET /v1/recipes/

Detailed metadata for a single loaded recipe.

Authentication: Required (X-API-Key).

Response body (200 OK):

All fields from GET /v1/recipes plus:

FieldTypeDescription
config_digeststring | nullsha256:<hex> of the recipe YAML, or null if unavailable.
algorithmsstring[]All algorithm classes evaluated during tuning.
best_algorithmstringAlgorithm class selected as best.
best_classstring | nullFully qualified class name of the best algorithm.
best_paramsobject | nullHyperparameters of the best algorithm.
best_scorenumber | nullValidation score of the best model. NaN and Inf are normalized to null.
metric"ndcg" | "map" | "recall" | "hit" | nullEvaluation metric used during tuning.
cutoffinteger | nullCutoff K used when computing the offline evaluation metric during tuning. This is unrelated to the per-request limit — it only describes how the recipe was scored at training time.
tuningobject | nullTuning metadata (tried_algorithms, n_trials, n_completed).
data_statsobject | nullTraining data statistics (n_rows, n_users, n_items).
recotem_versionstring | nullVersion of recotem that trained this artifact.
irspack_versionstring | nullVersion of irspack used during training.
recipe_hashstring | null64-character lowercase hex digest of the recipe configuration at training time (no sha256: prefix — distinct from config_digest).
trained_atstring (ISO 8601) | nullTimestamp when training completed.

Optional fields above are null for older artifacts that did not record them.

Status codes:

CodeConditionError code
200Recipe is loaded
404Recipe name does not exist in the registryRECIPE_NOT_FOUND
503Recipe exists but is not loadedRECIPE_UNAVAILABLE

curl example:

bash
curl -s http://localhost:8080/v1/recipes/purchase_log \
  -H "X-API-Key: <plaintext>" | jq .

Health and Metrics

GET /v1/health

Overall liveness and readiness status. Suitable for Kubernetes liveness and readiness probes.

Authentication: None (unauthenticated).

Response body:

json
{"status": "ok", "total": 3, "loaded": 3}
FieldTypeDescription
status"ok" | "degraded""ok" when every configured recipe is loaded. "degraded" when any recipe is unloaded. When total == 0, the status is always "ok".
totalintegerTotal number of recipe entries in the registry.
loadedintegerNumber of recipes successfully loaded and ready to serve.

Status codes:

CodeCondition
200All recipes are loaded.
503One or more recipes are not loaded.

Kubernetes readiness probes

A 503 response removes the pod from the Service endpoints. This is intentional — a pod where every recommendation request would return 503 should not receive traffic. Use GET /v1/health for both readiness and liveness probes.

curl example:

bash
curl -s http://localhost:8080/v1/health | jq .

GET /v1/health/details

Per-recipe health detail including load errors and artifact identifiers.

Authentication: Required (X-API-Key).

Per-recipe detail is behind authentication because it includes artifact key identifiers (kid) that should not be publicly discoverable. Use GET /v1/health for unauthenticated probe-safe status.

Response body:

json
{
  "status": "ok",
  "recipes": {
    "purchase_log": {
      "loaded": true,
      "trained_at": "2026-05-21T00:00:00Z",
      "best_class": "IALSRecommender",
      "kid": "prod-2026-q2"
    },
    "product_recs": {
      "loaded": false,
      "error": "signature mismatch"
    }
  }
}

Every recipe in the registry appears here, including stubs for recipes that failed to load at startup. Optional fields (trained_at, best_class, kid, error) are present only when their underlying value is set.

Status codes: Same as GET /v1/health503 when any recipe carries loaded: false or an error field.

curl example:

bash
curl -s http://localhost:8080/v1/health/details \
  -H "X-API-Key: <plaintext>" | jq .

GET /v1/metrics

Prometheus metrics exposition (opt-in).

Authentication: Required (X-API-Key).

Availability: This route is registered only when both conditions are met:

  1. RECOTEM_METRICS_ENABLED is set to a truthy value (1, true, yes, on).
  2. The recotem[metrics] extra is installed (pip install "recotem[metrics]").

This endpoint is excluded from the OpenAPI schema.

Prometheus scraper configuration

Unlike most Prometheus targets, /v1/metrics requires X-API-Key. Configure your scraper to send the header:

yaml
# prometheus.yml scrape config (Prometheus 2.45+)
scrape_configs:
  - job_name: recotem
    metrics_path: /v1/metrics
    static_configs:
      - targets: ["localhost:8080"]
    http_headers:
      X-API-Key:
        values: ["<plaintext>"]

Available metrics:

MetricTypeLabels
recotem_v1_requests_totalCounterrecipe, verb, status
recotem_v1_request_latency_secondsHistogramrecipe, verb
recotem_v1_batch_sizeHistogramrecipe, verb
recotem_v1_batch_element_errors_totalCounterrecipe, verb, code
recotem_v1_metadata_degraded_items_totalCounterrecipe, verb, kind
recotem_v1_validation_errors_outside_verb_totalCounter
recotem_model_loadedGaugerecipe
recotem_artifact_load_failures_totalCounterrecipe, reason
recotem_active_recipesGauge
recotem_swap_totalCounterrecipe, result
recotem_artifact_stat_failures_totalCounterrecipe
recotem_watcher_unhandled_errors_totalCounter
recotem_metadata_index_build_errors_totalCounterrecipe
recotem_metadata_serialization_errors_totalCounterrecipe, verb
recotem_recipe_rescan_errors_totalCounterrecipe
recotem_recommender_layout_unexpected_totalCounterrecipe
recotem_watcher_state_divergence_totalCounter
recotem_bigquery_storage_fallback_totalCounterreason
recotem_recipes_dir_scan_failures_totalCountererror_class

The verb label takes values recommend, recommend-related, batch-recommend, batch-recommend-related. The status label on recotem_v1_requests_total takes values ok, unknown_user, unknown_seed_items, no_candidates, unavailable, recipe_not_found, validation_error, and error. The reason label on recotem_artifact_load_failures_total takes values read, parse, hmac, header_json, deserialize, metadata, yaml, unexpected, dir_scan, and timeout.

curl example:

bash
curl -s http://localhost:8080/v1/metrics \
  -H "X-API-Key: <plaintext>"

Error Format

All error responses use a flat JSON body with at minimum detail (human-readable) and code (machine-readable UPPER_SNAKE_CASE).

Standard error body:

json
{"detail": "recipe purchase_log is not loaded", "code": "RECIPE_UNAVAILABLE"}

Validation error body (422 only): Includes a request_id and a structured errors array.

json
{
  "request_id": "a1b2c3d4e5f6",
  "detail": "Request validation failed",
  "code": "VALIDATION_ERROR",
  "errors": [
    {"loc": ["body", "limit"], "msg": "ensure this value is less than or equal to 1000", "type": "value_error.number.not_le"}
  ]
}

Internal error body (500 only): Includes a request_id for correlation with server logs.

json
{"detail": "internal error", "code": "INTERNAL_ERROR", "request_id": "a1b2c3d4e5f6"}

Error Codes

CodeHTTPWhen
RECIPE_UNAVAILABLE503Recipe exists in the registry but its artifact is not loaded.
RECIPE_NOT_FOUND404Recipe name does not exist in the registry at all.
UNKNOWN_USER404user_id was not present in the training idmap.
UNKNOWN_SEED_ITEMS404All items in seed_items are unknown to the model.
NO_CANDIDATES404Seed items are known but no candidates survive the ranking stage.
VALIDATION_ERROR422 (HTTP) / per-element (batch)Request or element body failed schema validation.
MISSING_API_KEY401X-API-Key header is absent.
INVALID_API_KEY401X-API-Key does not match any configured key.
INTERNAL_ERROR500 (HTTP) / per-element (batch)Unhandled exception during request processing.

Middleware

TrustedHostMiddleware

RECOTEM_ALLOWED_HOSTS (default: 127.0.0.1,localhost) controls the Host header allow-list. Requests with a Host header not in this list receive 400 Bad Request. This applies to every endpoint including GET /v1/health.

In Kubernetes, kubelet probes send Host: localhost by default — this is why localhost is always in the default allow-list. When exposing via Ingress, add the Ingress hostname to RECOTEM_ALLOWED_HOSTS explicitly.

CORS

RECOTEM_ALLOWED_ORIGINS (default: empty = deny all) sets the CORS allow-list. When empty, all CORS preflight requests are denied. Provide a comma-separated list of origins to allow browser-based clients.

yaml
RECOTEM_ALLOWED_ORIGINS: "https://app.example.com,https://admin.example.com"

OpenAPI Documentation

Interactive documentation is available at /docs (Swagger UI) and /redoc. The raw schema is at /openapi.json.

Development environments only

These three endpoints are available only when RECOTEM_ENV is set to development, dev, or test. They are disabled in all other environments. Do not rely on them in production deployments.