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.
Authentication
Section titled “Authentication”BetStackWeb.Plugs.ApiKeyAuthverifies theSTX-ACCESS-KEY/STX-ACCESS-TIMESTAMP/STX-ACCESS-SIGNATUREheaders (BetStack.Auth.ApiKeys.verify_request/5) and assigns the result intoconn.assigns::api_key,:current_user,:account_id. Never addBetStackWeb.APIContext(aPlug) to the:api_v1pipeline or call itscall/2— that populates the Absinthe GraphQL context, which a plain REST controller has no use for; rely onApiKeyAuth’sconn.assignsinstead. This doesn’t rule out callingAPIContext’s plain utility functions directly where one already exists and does the right thing — e.g. every REST controller needing the caller’s IP callsAPIContext.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 doplug :accepts, ["json"]plug RemoteIpplug BetStackWeb.Plugs.ApiKeyAuthendRemoteIpis 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:
Apply it only to mutating actions, soplug :require_write_scope when action in [:create, :delete, :delete_batch, :delete_all]defp require_write_scope(conn, _opts) doif conn.assigns.api_key.scope == :read_write doconnelseconn |> put_status(403) |> json(%{error: "This API key does not have write access"}) |> halt()endend
:read_onlykeys work for everyGET.
Routing
Section titled “Routing”- 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
/portfolioscope andBetStackWeb.Api.V1.Portfolio.*namespace:scope "/api/v1", BetStackWeb.Api.V1 dopipe_through :api_v1get "/trades", TradeController, :index# ...scope "/portfolio", Portfolio doget "/settlements", SettlementController, :indexget "/deposits", DepositsController, :indexget "/withdrawals", WithdrawalsController, :indexendend - 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, notPOST. Order cancellation originally shipped asPOST /orders/:order_id/canceletc. (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 swallowDELETE /orders/batchedasorder_id: "batched".
Pagination: cursor-based, not offset (Kalshi-style)
Section titled “Pagination: cursor-based, not offset (Kalshi-style)”- Every list endpoint takes
limitand an opaquecursor, neverpage. - Cursor is built via the
paginatorHex package on keyset columns — usually{inserted_at: :desc, id: :asc}, but check the schema’s actual primary key first:AccountTradehas no:idcolumn (composite PKtrade_id+account_id), so its cursor uses{inserted_at: :desc, trade_id: :asc}instead. Don’t assume every schema has a plain:idto sort by. - Response shape:
%{<plural_resource>: [...], cursor: <opaque string or nil>}— nototal_countfield; 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) andrest_api_max_limit/0(200) rather than adding per-resource limit config. Wired throughconfig/config.exs→config/runtime.exs(REST_API_DEFAULT_LIMIT/REST_API_MAX_LIMIT) → everycharts/*-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 all —
EventListProcholds 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 approachrest_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 againstBetStack.Data.Event, and the resulting page of ids is hydrated into fullEventInfostructs viaEventListProc.list_any_by_id/1— giving genuine cursor pagination and preservingEventInfo’s computed fields (participants, etc.) that don’t exist as raweventscolumns. 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_timeas an alternative to the defaultinserted_at), derive the query’sorder_byfrom whatevercursor_fieldsresolves to, rather than hardcoding both separately — seeEvent.paginate/1. Watch the shape: Paginator’scursor_fieldsis[field: direction], Ecto’sorder_byis[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 Ectoorder_byArgumentError; dialyzer/tests are what catch this, not a type system, so run both before assuming it’s right).
- Markets reuses the existing in-memory cursor
(
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, andcast_enums/3for these (UUIDs, plain strings, and enum atoms respectively). All three are named without a_csvsuffix on purpose: a caller only needs to know that multiple values come back parsed, not the wire encoding.status=open,cancelledon Orders andstatus=open,closedon Markets both go throughcast_enums/3— it was extracted once a second controller needed the same comma-split-then-validate logicOrder’s status filter already had inline, so don’t reimplement it a third time either. ParamHelpers.parse_uuid_list/1is 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 plucksorder_idout of each entry itself (seeOrderController.parse_order_ids/1) rather than callingparse_uuid_list/1directly on the raw body. Useparse_uuids/1for 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
statusfilter 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.
Param validation
Section titled “Param validation”- Never
String.to_atom/1on a request param. Every enum-like string param goes throughParamHelpers.cast_enum/3(single value) orParamHelpers.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 thefield: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/1parses, defaults, and clamps againstrest_api_default_limit/0/rest_api_max_limit/0in 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 passingnilfor an absent filter — some don’t (e.g.Order.build_query/1guards:statusbut not:ids/:market_ids/:client_ids/:account_id). Omit absent keys entirely viaParamHelpers.maybe_put/3rather than assuming a nil-guard exists.
Response shape
Section titled “Response shape”- Error envelope:
%{error: "message"}. No structured/typed error envelope exists yet for REST — don’t invent one for a single new resource. - Status codes:
400bad params,401missing/invalid signature,403insufficient scope or disallowed account state,404not 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),422rejected by the owning domain process (a business-rule rejection, e.g. insufficient funds, market closed). - The view module renders via
to_map/1, notto_api_map/1—to_api_map/1is 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 existingto_map/1, add one following this convention rather than reusingas_readable/1unmodified (it doesn’t do the Money/Decimal conversion) or building an ad hoc shape.
Ownership / single-writer rule
Section titled “Ownership / single-writer rule”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.

