Skip to content

REST conventions

This document is the house style for every endpoint under /api/v1 — the plain-REST, Ed25519-API-key-authenticated surface for programmatic clients, separate from the GraphQL API (docs/api/). Established by SX-11867 (Orders: GET /orders, GET /orders/:id, POST /orders) and extended by SX-11887 (order cancellation, Trades, Events, Markets, Settlements, Deposits, Withdrawals).

Read this in full before adding or modifying any /api/v1 endpoint. Deviating from any rule here should be a deliberate, called-out choice (documented in the endpoint’s @doc), not an accident.

  • BetStackWeb.Plugs.ApiKeyAuth verifies the STX-ACCESS-KEY / STX-ACCESS-TIMESTAMP / STX-ACCESS-SIGNATURE headers (BetStack.Auth.ApiKeys.verify_request/5) and assigns the result into conn.assigns: :api_key, :current_user, :account_id. Never add BetStackWeb.APIContext (a Plug) to the :api_v1 pipeline or call its call/2 — that populates the Absinthe GraphQL context, which a plain REST controller has no use for; rely on ApiKeyAuth’s conn.assigns instead. This doesn’t rule out calling APIContext’s plain utility functions directly where one already exists and does the right thing — e.g. every REST controller needing the caller’s IP calls APIContext.format_ip_address/1 (a pure function, unrelated to the Absinthe-context-populating half of that module) rather than reimplementing IP-tuple formatting. The rule is about the plug (Absinthe context), not the module as a whole.
  • Router pipeline (lib/bet_stack_web/router.ex):
    pipeline :api_v1 do
    plug :accepts, ["json"]
    plug RemoteIp
    plug BetStackWeb.Plugs.ApiKeyAuth
    end
    RemoteIp is required even for read-only resources you add later — any action needing the caller’s real IP (geo-fencing, audit) depends on it being in the pipeline already.
  • Write-scope enforcement is a per-action controller plug, not global:
    plug :require_write_scope when action in [:create, :delete, :delete_batch, :delete_all]
    defp require_write_scope(conn, _opts) do
    if conn.assigns.api_key.scope == :read_write do
    conn
    else
    conn |> put_status(403) |> json(%{error: "This API key does not have write access"}) |> halt()
    end
    end
    Apply it only to mutating actions, so :read_only keys work for every GET.
  • Version via URL path: /api/v1/..., never a header or query param.
  • scope "/api/v1", BetStackWeb.Api.V1 do ... end, pipe_through :api_v1.
  • Group related sub-resources under a nested scope and a matching module namespace when they share a conceptual home, rather than flattening everything into one directory — e.g. Settlements/Deposits/Withdrawals (all account-ledger-style history) live under a nested /portfolio scope and BetStackWeb.Api.V1.Portfolio.* namespace:
    scope "/api/v1", BetStackWeb.Api.V1 do
    pipe_through :api_v1
    get "/trades", TradeController, :index
    # ...
    scope "/portfolio", Portfolio do
    get "/settlements", SettlementController, :index
    get "/deposits", DepositsController, :index
    get "/withdrawals", WithdrawalsController, :index
    end
    end
  • Never call BetStackWeb.Resolvers.* from a REST controller. Call the same underlying domain functions the resolver calls (AccountProc, Order, MarketListProc, etc.) directly. Where a business rule lives only in a resolver (not in the schema’s own changeset), port it inline into the REST controller as a private function with a comment pointing at the resolver function it mirrors — don’t assume a schema’s own changeset enforces everything a GraphQL resolver enforces; check explicitly.
  • Cancel/delete-shaped mutations use DELETE, not POST. Order cancellation originally shipped as POST /orders/:order_id/cancel etc. (mirroring the GraphQL mutation names) and was revised to proper REST verbs: DELETE /orders/:order_id (single), DELETE /orders/batched (body %{"orders" => [%{"order_id" => id}, ...]} — a list of objects, not a flat id list), DELETE /orders/all. When a resource has literal-segment routes (batched, all) alongside a :id-style wildcard route under the same verb, declare the literal routes first — Phoenix’s router is first-match-wins, so a wildcard declared earlier would swallow DELETE /orders/batched as order_id: "batched".

Pagination: cursor-based, not offset (Kalshi-style)

Section titled “Pagination: cursor-based, not offset (Kalshi-style)”
  • Every list endpoint takes limit and an opaque cursor, never page.
  • Cursor is built via the paginator Hex package on keyset columns — usually {inserted_at: :desc, id: :asc}, but check the schema’s actual primary key first: AccountTrade has no :id column (composite PK trade_id + account_id), so its cursor uses {inserted_at: :desc, trade_id: :asc} instead. Don’t assume every schema has a plain :id to sort by.
  • Response shape: %{<plural_resource>: [...], cursor: <opaque string or nil>} — no total_count field; keyset pagination doesn’t cheaply support total counts, and callers shouldn’t rely on one.
  • Page-size limits are generic, resource-agnostic config — reuse BetStackConfig.rest_api_default_limit/0 (100) and rest_api_max_limit/0 (200) rather than adding per-resource limit config. Wired through config/config.exsconfig/runtime.exs (REST_API_DEFAULT_LIMIT/REST_API_MAX_LIMIT) → every charts/*-values.yaml.
  • Deliberate deviations are allowed — but must be documented in the endpoint’s @doc, never silent, and revisited once the deviation actually bites:
    • Markets reuses the existing in-memory cursor (MarketListProc.query_markets_paginated/1, backed by the SQLite market index) rather than building a second Ecto/Paginator mechanism for the same data.
    • Events originally shipped without cursor pagination at allEventListProc holds every event in memory with no persisted, cursor-able query layer, so the endpoint was limit-only, on the assumption that event volume would never approach rest_api_max_limit/0 (200). That assumption didn’t hold in practice (a real client hit the 200-event ceiling with events genuinely unreachable beyond it), so it was revised: Event.paginate/1 / build_query/1 (new) do real Ecto/Paginator keyset pagination directly against BetStack.Data.Event, and the resulting page of ids is hydrated into full EventInfo structs via EventListProc.list_any_by_id/1 — giving genuine cursor pagination and preserving EventInfo’s computed fields (participants, etc.) that don’t exist as raw events columns. Lesson: a documented deviation is a bet, not a permanent exemption — check back in when the assumption behind it turns out wrong, don’t leave it as-is because it was “deliberate” once.
    • When a resource needs pagination sorted by more than one possible key (Events supports sort_by=start_time as an alternative to the default inserted_at), derive the query’s order_by from whatever cursor_fields resolves to, rather than hardcoding both separately — see Event.paginate/1. Watch the shape: Paginator’s cursor_fields is [field: direction], Ecto’s order_by is [direction: field] — inverted — so converting between them means flipping each pair, not passing the list through as-is (this broke on the first attempt with an opaque Ecto order_by ArgumentError; dialyzer/tests are what catch this, not a type system, so run both before assuming it’s right).

Multi-value filters: comma-separated, not bracket-array (Kalshi-style)

Section titled “Multi-value filters: comma-separated, not bracket-array (Kalshi-style)”
  • Every multi-value query-string filter (market_ids, event_ids, status, sports, competitions, etc.) is a comma-separated string — market_ids=id1,id2 — never bracket-array (market_ids[]=id1&market_ids[]=id2).
  • This matches Kalshi’s own public API (docs.kalshi.com/api-reference/market/get-markets): their one multi-value filter, tickers, is comma-separated; every other filter they expose is single-value only. Verified against their docs directly — don’t assume.
  • Use BetStackWeb.Api.V1.ParamHelpers.parse_uuids/1, parse_strings/1, and cast_enums/3 for these (UUIDs, plain strings, and enum atoms respectively). All three are named without a _csv suffix on purpose: a caller only needs to know that multiple values come back parsed, not the wire encoding. status=open,cancelled on Orders and status=open,closed on Markets both go through cast_enums/3 — it was extracted once a second controller needed the same comma-split-then-validate logic Order’s status filter already had inline, so don’t reimplement it a third time either.
  • ParamHelpers.parse_uuid_list/1 is a different thing — it validates an already-decoded Elixir list, e.g. a JSON DELETE-body array. Note the shape isn’t always a flat id list: OrderController.delete_batch/2’s body is %{"orders" => [%{"order_id" => id}, ...]} (a list of objects), so it plucks order_id out of each entry itself (see OrderController.parse_order_ids/1) rather than calling parse_uuid_list/1 directly on the raw body. Use parse_uuids/1 for query strings, parse_uuid_list/1-style validation for body arrays. Don’t conflate the two just because both end up validating UUIDs.
  • It’s fine to support multi-value filtering on a param even where Kalshi’s own equivalent is single-value-only (our status filter on Markets, for example) when the capability already exists elsewhere (GraphQL) and REST shouldn’t regress it. Take Kalshi’s comma-separated encoding, not necessarily its single-value scope limitation — but make that call explicitly, don’t default to it by accident.
  • Never String.to_atom/1 on a request param. Every enum-like string param goes through ParamHelpers.cast_enum/3 (single value) or ParamHelpers.cast_enums/3 (comma-separated multi-value, e.g. status=open,cancelled — see the comma-separated filter convention above) — both are safe atom lookups against a known allow-list. This applies project-wide, but REST controllers are the main place raw client strings enter the system.
  • Error messages must name the offending field: cast_enum(value, allowed, field: "order_type") produces "order_type is required" / "order_type has invalid value: foo", never a generic "value is required". A generic message shipped once and confused a real API consumer during manual testing — keep the field: option on every new validator so it can’t regress.
  • UUID params: ParamHelpers.fetch_uuid/2 (required single value, field- naming error), parse_uuid/2 (optional single value), parse_uuids/1 (comma-separated multi-value, see above), parse_uuid_list/1 (JSON body array, see above).
  • limit: ParamHelpers.clamp_limit/1 parses, defaults, and clamps against rest_api_default_limit/0/rest_api_max_limit/0 in one call.
  • When assembling a keyword list for a build_query/1-style domain function, check whether it nil-guards every optional key uniformly before passing nil for an absent filter — some don’t (e.g. Order.build_query/1 guards :status but not :ids/:market_ids/:client_ids/:account_id). Omit absent keys entirely via ParamHelpers.maybe_put/3 rather than assuming a nil-guard exists.
  • Error envelope: %{error: "message"}. No structured/typed error envelope exists yet for REST — don’t invent one for a single new resource.
  • Status codes: 400 bad params, 401 missing/invalid signature, 403 insufficient scope or disallowed account state, 404 not found or not owned (an id belonging to another account must 404, never 403 — don’t confirm existence of something the caller doesn’t own), 422 rejected by the owning domain process (a business-rule rejection, e.g. insufficient funds, market closed).
  • The view module renders via to_map/1, not to_api_map/1to_api_map/1 is GraphQL-scalar-specific (keeps Money as structs for Absinthe’s scalar layer) and wrong for plain REST/JSON. REST responses use: Money as a subunit int (Money.to_subunit_int/1), Decimal as a plain number (DecimalUtils.as_number/1), ids as strings (RawUUID.as_string!/1). If a schema has no existing to_map/1, add one following this convention rather than reusing as_readable/1 unmodified (it doesn’t do the Money/Decimal conversion) or building an ad hoc shape.

The project-wide rule applies here without exception: mutating actions always go through the owning process (AccountProc, etc.) — never write a row directly from a REST controller. Reads for listing/history hit the DB directly, same as the equivalent GraphQL query resolver already does; that’s reporting/historical access, not a live decision gated on a DB read.

Blank JSON bodies on body-less endpoints (app-wide, not /api/v1-specific)

Section titled “Blank JSON bodies on body-less endpoints (app-wide, not /api/v1-specific)”

Several endpoints genuinely take no required body (DELETE /orders/:order_id, DELETE /orders/all). Many HTTP clients (Postman chief among them) attach Content-Type: application/json to a request regardless of whether there’s an actual body, and a “raw” body tab left empty often sends a stray newline/whitespace rather than a byte-for-byte-empty body. Plain Plug.Parsers.JSON only special-cases a literally empty body ("") as %{} — anything else goes through Jason.decode!/1 and a blank-but-not- quite-empty body raises Plug.Parsers.ParseError, which surfaces as a raw Plug/Phoenix debug page instead of this API’s %{error: "..."} envelope, before the request ever reaches a controller.

lib/bet_stack_web/endpoint.ex’s Plug.Parsers now uses BetStackWeb.Plugs.LenientJsonParser in place of :json (app-wide — this isn’t scoped to /api/v1, since the parser is configured at the Endpoint, not the Router) — a near-identical reimplementation of Plug.Parsers.JSON that trims the body before the empty check, so whitespace-only bodies are also treated as %{}. Genuinely malformed (non-blank) JSON still raises Plug.Parsers.ParseError exactly as before — this only widens what counts as “no body,” it doesn’t loosen JSON validation. See test/bet_stack_web/plugs/lenient_json_parser_test.exs for the exact behavior contract, and don’t reach for Plug.Parsers.JSON (the :json atom) directly again without checking whether this is why it was replaced.