Skip to content

API Gateway REST/WebSocket Training

Objective

Train on configuring and using pm-api-gwy for REST order entry, reference-data and history queries, admin operations, Swagger/OpenAPI exploration, WebSocket event handling, and multi-process logical separation.

You will practice:

  • generating api_gateways config with bearer credentials
  • starting a named API gateway process
  • using REST endpoints with trading and read-only keys
  • querying reference data and order/trade history over REST
  • exercising admin REST endpoints with a resolved ADMIN role
  • receiving private and market-data events over WebSocket
  • splitting API gateway processes by ALF gateway_id

Background reading

Prerequisites

  • Chapters 01-24 completed.
  • Engine and stats commands available from the current environment.
  • REST examples available in docs/examples/REST. On a container or pipx install that directory is inside the package: set EX=$(python -c "from edumatcher.config_deploy import examples_root; print(examples_root().parent)") and read docs/examples as $EX in Exercises 6, 7 and 12. Exercise 7's C example is optional: it needs make and a C compiler, neither of which is installed in the container image. Exercise 6's Python client provides the equivalent API workflow.
  • The curl commands below assume the lab config from Exercise 1 and are run from the directory holding engine_config.yaml.
  • A terminal for pm-engine, one for pm-stats, one for pm-api-gwy, and one or more client terminals.
  • Optional: pm-audit running, if you want to see a populated reply instead of AUDIT_INDEX_UNAVAILABLE from the audited order-lifecycle call in Exercise 10.
  • This chapter is a hands-on tour of the normative REST and WebSocket API Reference — keep it open alongside this chapter for the full endpoint-by-endpoint contract (arguments, replies, and error codes) behind every call made here.

Recommended startup order:

  1. Generate engine_config.yaml.
  2. Start pm-engine --verbose.
  3. Start pm-stats.
  4. Start pm-api-gwy --instance desk.

Background

pm-api-gwy is an HTTP and WebSocket wrapper around the matching engine. It uses bearer tokens from api_gateways.<NAME>.credentials. A credential with a non-null gateway_id can submit and manage orders for that ALF gateway. A credential with gateway_id: null is read-only and can use the session, halts, reference, market-data, history, and public WebSocket paths.

Multiple API gateway processes are configured with top-level api_gateways:

api_gateways:
  desk:
    port: 8080
    credentials:
      - api_key: key-trader-demo
        gateway_id: TRADER01
  algos:
    port: 8081
    credentials:
      - api_key: key-algo-demo
        gateway_id: ALGO01

A non-null gateway_id may appear in only one api_gateways entry. This keeps private session and event state owned by one API gateway process. Read-only gateway_id: null credentials may appear in multiple entries.

Authentication model: API key → gateway_id → engine role

Every REST and WebSocket call carries one bearer token: Authorization: Bearer <api_key>. The gateway looks the key up in its own credential table and resolves it to a session holding just api_key, gateway_id, and description — nothing more. Three access levels follow directly from that one resolved gateway_id:

Resolved gateway_id Access
null Read-only: session, halts, reference data, history, public market data, /healthz
a configured ALF gateway_id Trading: everything read-only gets, plus order/quote/combo/OCO submission and cancellation scoped to that one gateway
a configured ALF gateway_id whose engine role is ADMIN Everything trading gets, plus every /api/v1/admin/* endpoint

The important detail is the third row: the API gateway does not store an "is admin" flag on the credential itself. It asks the engine what role the resolved gateway_id has, and only accepts ADMIN for admin endpoints. This is why OPS01:ADMIN in Exercise 1 configures the role on the ALF gateway, not on the API key — the key is only a pointer to that gateway identity.

See Appendix: REST API Reference — Auth and roles for the normative statement of this model, including how keys are provisioned.

Exercise 1: Generate a Single API Gateway Config

Generate a local lab config with trading keys and one read-only dashboard key. --sessions-enabled lets Exercise 10 drive the session, and --cb-levels L1:0.05:30 gives every symbol a 5% circuit-breaker level with a 30-minute halt, which the admin halt in the same exercise needs (a symbol with no circuit breaker configured refuses it with HALT_REJECTED, AAPL has no circuit breaker configured):

pm-config-gen \
  --symbols AAPL MSFT \
  --participants TRADER01 TRADER02 OPS01:ADMIN \
  --outstanding-shares AAPL:15400000000 \
  --outstanding-shares MSFT:7430000000 \
  --sessions-enabled \
  --cb-levels L1:0.05:30 \
  --api-gateway \
  --api-gateway-name desk \
  --api-key key-trader-demo:TRADER01 \
  --api-key key-trader02-demo:TRADER02 \
  --api-key key-ops-demo:OPS01 \
  --api-key key-readonly-demo:null \
  --no-api-gateway-generate-keys \
  --api-gateway-host 127.0.0.1 \
  --api-gateway-port 8080 \
  --seed 20260624 \
  --output engine_config.yaml --force

Back up, check, deploy — then restart

This chapter starts from a fresh configuration (its operator is OPS01), and --force replaces your engine_config.yaml: save a copy first if you want to come back to it (cp engine_config.yaml engine_config.before-ch25.yaml). As always, nothing changes until you deploy it and restart the engine: pm-cverifier engine_config.yaml, then pm-config-deploy engine_config.yaml.

Inspect the generated section:

grep -A40 '^api_gateways:' engine_config.yaml

Expected behavior:

  • top-level api_gateways: exists
  • desk: has port: 8080
  • credentials exist for TRADER01, TRADER02, OPS01, and one read-only key

For this training exercise, the command supplies fixed, easy-to-type demo keys. Define shell variables once in each terminal you use for curl:

KEY_TRADER=key-trader-demo
KEY_OPS=key-ops-demo          # OPS01's resolved engine role is ADMIN
KEY_RO=key-readonly-demo

You can also use the Config GUI's API subtab to generate keys for selected participants and choose the generated key length.

Exercise 10 uses $KEY_OPS for every admin REST call. Where a tool cannot expand shell variables (the WebSocket sessions in Exercise 9), enter the matching demo key shown above.

Checkpoint: the config contains the key-trader-demo, key-ops-demo, and key-readonly-demo credentials bound to the expected gateway identities.

Exercise 2: Start the API Gateway Process

Start the core processes with the generated config:

pm-engine --verbose
pm-stats
pm-api-gwy --instance desk

If the config contains only one api_gateways entry, --instance can be omitted. Use it anyway in labs so the selected process is explicit.

Sessions are enabled, so the engine starts in CLOSED and refuses orders until the day is opened. Open it from the operator console (pm-admin --id OPS01, then SESSION|STATE=PRE_OPEN and SESSION|STATE=CONTINUOUS) before Exercise 4; Exercise 10 shows the REST equivalent.

Expected behavior:

  • pm-api-gwy binds to 127.0.0.1:8080
  • startup fails if api_gateways.desk.enabled is false
  • startup fails if the same non-null gateway_id is assigned to two named entries

Checkpoint: GET /api/v1/status is reachable once the process is running.

Exercise 3: Explore Swagger with a Bearer Key

Open Swagger in a browser:

http://127.0.0.1:8080/docs

Use the Authorize control and enter the trading key key-trader-demo. Then run:

curl -H "Authorization: Bearer $KEY_TRADER" \
  http://127.0.0.1:8080/api/v1/status

Try the same request with a read-only key, then try a write endpoint with the read-only key.

Expected behavior:

  • status works for a trading key and answers 403 READ_ONLY for a read-only key (it reports the caller's gateway, which a read-only key does not have)
  • order entry rejects a read-only key
  • Swagger lists the same route shapes exposed by /openapi.json

Checkpoint: you can authenticate in Swagger and explain which key is allowed to submit orders.

Exercise 4: Submit and Cancel an Order over REST

Submit a limit order with a trading key:

curl -X POST http://127.0.0.1:8080/api/v1/orders \
  -H "Authorization: Bearer $KEY_TRADER" \
  -H 'Content-Type: application/json' \
  -d '{
    "symbol": "AAPL",
    "side": "BUY",
    "order_type": "LIMIT",
    "quantity": 100,
    "price": 140.00,
    "tif": "DAY"
  }'

The price is well below AAPL's market on purpose, so the order rests instead of trading. Capture the returned order_id, then cancel it with a request tag:

curl -X DELETE 'http://127.0.0.1:8080/api/v1/orders/ORDER_ID?request_tag=rest-cxl-001' \
  -H "Authorization: Bearer $KEY_TRADER"

Expected behavior:

  • the submit call returns an accepted response or an engine validation error
  • a valid cancel request targets the authenticated credential's gateway_id
  • request_tag is echoed on the cancel response/event and identifies this request, not the original order
  • synchronous validation errors include both API error.code and canonical error.reject_code; when supplied, client_tag or request_tag is echoed in the error body
  • read-only keys cannot submit or cancel orders

Checkpoint: you can submit an order and observe its cancel path or explain the engine-side validation error.

Exercise 5: Query Reference Data

Reference-data endpoints expose compiled, mostly-static configuration: tick sizes, risk bands, index definitions, and the session schedule. They change only when an admin reloads the reference bundle (Exercise 10) — never per trade or per order.

Fetch the full bundle in one call:

curl -H "Authorization: Bearer $KEY_RO" \
  http://127.0.0.1:8080/api/v1/reference

Then fetch the individual sections used most often:

curl -H "Authorization: Bearer $KEY_RO" \
  http://127.0.0.1:8080/api/v1/reference/symbols

curl -H "Authorization: Bearer $KEY_RO" \
  http://127.0.0.1:8080/api/v1/reference/risk

curl -H "Authorization: Bearer $KEY_RO" \
  http://127.0.0.1:8080/api/v1/reference/schedule

curl -H "Authorization: Bearer $KEY_RO" \
  http://127.0.0.1:8080/api/v1/reference/config-version

Every reference reply carries config_version — a content hash of the compiled bundle. Compare the value returned by /reference/config-version against the one embedded in /reference/symbols; they must match. A client can poll this one small value instead of every field to notice a config change.

Expected behavior:

  • a read-only key can call every /reference/* endpoint
  • tick_decimals for AAPL/MSFT in /reference/symbols matches what you passed to pm-config-gen
  • config_version is identical across every /reference/* reply taken at the same point in time

Checkpoint: you can explain the difference between GET /api/v1/reference (one round-trip) and the per-section endpoints, and what config_version is for.

Exercise 6: Use the Python REST Example

From the REST example directory, run the Python client or adapt it with the key and port from your config:

cd docs/examples/REST/python        # or: cd $EX/REST/python
EDUMATCHER_API_URL=http://127.0.0.1:8080 \
EDUMATCHER_API_KEY=$KEY_TRADER \
python3 demo_info.py

Use the example source as a reference for adding status, order-entry, and cancel calls to a test harness.

Checkpoint: the Python client reaches the gateway and sends authenticated requests.

Optional Exercise 7: Use the C REST Example

Build and run the C example from the REST example directory:

cd docs/examples/REST/c             # or: cd $EX/REST/c
make
EDUMATCHER_API_KEY=$KEY_TRADER ./demo_info

Expected behavior:

  • the client sends an Authorization header
  • status or order-entry responses are printed as JSON
  • connection errors identify an unavailable gateway process or wrong port

Optional checkpoint: the C example can call the running API gateway with a configured key.

Exercise 8: Query Order and Trade History

History endpoints read from pm-stats's database, so pm-stats must be running and must have been running while your orders and trades happened — it cannot reconstruct events from before it started.

Submit and fill an order (repeat Exercise 4 with a marketable price, or cross TRADER01 against TRADER02), then query its lifecycle with a trading key:

curl -H "Authorization: Bearer $KEY_TRADER" \
  "http://127.0.0.1:8080/api/v1/history/orders?symbol=AAPL&limit=10"

curl -H "Authorization: Bearer $KEY_TRADER" \
  http://127.0.0.1:8080/api/v1/history/orders/ORDER_ID

curl -H "Authorization: Bearer $KEY_TRADER" \
  "http://127.0.0.1:8080/api/v1/history/fills?symbol=AAPL"

The public trade tape and daily OHLCV accept any authenticated key, not only a trading key:

curl -H "Authorization: Bearer $KEY_RO" \
  "http://127.0.0.1:8080/api/v1/history/trades?symbol=AAPL&limit=5"

curl -H "Authorization: Bearer $KEY_RO" \
  "http://127.0.0.1:8080/api/v1/history/daily?symbol=AAPL"

Walk a second page using the cursor from the first reply:

curl -H "Authorization: Bearer $KEY_RO" \
  "http://127.0.0.1:8080/api/v1/history/trades?symbol=AAPL&limit=5&after=NEXT_CURSOR"

Every history call returns rows oldest first, a page at a time. On a data directory that has seen earlier chapters the first page is therefore old traffic, not your new order, and /history/daily totals include everything pm-stats has recorded today. Look an order up by its id (/history/orders/ORDER_ID), narrow the range with from=<ISO timestamp> (or date=YYYY-MM-DD), or follow next_cursor to reach the recent rows.

Expected behavior:

  • /history/orders and /history/fills are scoped to the caller's own gateway — TRADER01's key never sees TRADER02's orders
  • /history/trades and /history/daily are public and show both sides
  • every page carries next_cursor: a string while "has_more": true (pass it back unchanged as after for the next page) and null on the last page
  • if pm-stats is not running, every history call returns 503 STATS_DB

Checkpoint: you can explain why /history/orders is gateway-scoped but /history/trades is not.

Exercise 9: Observe WebSocket Events

Connect to private events before submitting new orders:

python3 -m websockets ws://127.0.0.1:8080/api/v1/ws/private   # needs `pip install websockets`

Once connected, the CLI waits for you to type. Send the authentication message as your first input, then press Enter:

> {"api_key": "key-trader-demo"}
< {"type": "authenticated", "gateway_id": "TRADER01", "stream_seq": 10}
< {"type": "orders.snapshot", "gateway_id": "TRADER01", ...}

(stream_seq is the gateway's private-stream counter, so the number differs; the orders.snapshot that follows lists your current orders and positions before any live event.)

In another terminal, submit or cancel an order with the same key. Observe the private event stream.

Then connect to a public market-data WebSocket with a read-only key:

python3 -m websockets ws://127.0.0.1:8080/api/v1/ws/market-data

After authentication, send a subscription control message:

> {"api_key": "key-readonly-demo"}
< {"type": "authenticated"}
> {"action": "subscribe", "symbols": ["AAPL"], "channels": ["book", "trades"]}
< {"type": "subscription", "data": {"items": [...], "always": ["session", "circuit_breaker", "news"], "rejected": []}}

Each rule can have its own symbols, so one socket can carry an overview plus a focused instrument:

> {"action": "subscribe", "items": [
    {"symbols": ["*"],    "channels": ["book", "trades"]},
    {"symbols": ["AAPL"], "channels": ["depth"]}
  ]}

The book is also available without a socket. Compare it with what the subscription just sent:

curl -s -H "Authorization: Bearer $KEY_RO" \
  "http://127.0.0.1:8080/api/v1/book/AAPL?levels=3"
curl -s -H "Authorization: Bearer $KEY_RO" http://127.0.0.1:8080/api/v1/book

Each price comes as a display price and as *_ticks. The reply's seq is the seq of the book frame for the same snapshot, so a client that reads REST first and then subscribes can drop frames at or below it.

Expected behavior:

  • GET /book/AAPL matches the book frame the subscription sent, with the same seq when the book has not changed since
  • GET /book/NOSUCH answers 404 UNKNOWN_SYMBOL
  • private events correspond to the authenticated trading gateway
  • public market-data access does not require a non-null gateway_id
  • stale or unknown bearer keys are rejected
  • every event carries topic and a per-topic seq; a jump in seq for a topic means your client read too slowly and events were dropped
  • session, circuit_breaker and news arrive whether or not you subscribed — the ack lists them under always
  • an order.fill event's data.liquidity_flag tells you whether your order supplied liquidity (MAKER, it was resting) or removed it (TAKER, it crossed the spread) — no join against another feed required

Checkpoint: you can explain when to use REST responses versus WebSocket events for order outcomes.

Exercise 10: Admin REST — Session Control, Risk, and Cross-Gateway Orders

Confirm the resolved role first, with OPS01's key from Exercise 1:

curl -H "Authorization: Bearer $KEY_OPS" \
  http://127.0.0.1:8080/api/v1/status

gateway_role must read ADMIN; the reply also includes gateway_count, a field only ADMIN callers get back. Now try the same idea with a trading key:

curl -i -H "Authorization: Bearer $KEY_TRADER" \
  http://127.0.0.1:8080/api/v1/admin/gateways

Expect 403 ROLE_DENIED — a trading key is never accepted on /admin/*, regardless of which gateway it is bound to.

Trigger and resume a circuit-breaker halt with the ADMIN key. The symbol is part of the path; the body carries only the optional level (and reason):

curl -X POST http://127.0.0.1:8080/api/v1/admin/symbols/AAPL/halt \
  -H "Authorization: Bearer $KEY_OPS" \
  -H 'Content-Type: application/json' \
  -d '{"level": "L1"}'

curl -H "Authorization: Bearer $KEY_OPS" \
  http://127.0.0.1:8080/api/v1/halts

curl -X POST http://127.0.0.1:8080/api/v1/admin/symbols/AAPL/resume \
  -H "Authorization: Bearer $KEY_OPS"

Request a session transition. This call waits for the engine's verdict instead of firing and forgetting. If you have not opened the day yet, send PRE_OPEN first; the engine only accepts the legal next phase:

curl -i -X POST http://127.0.0.1:8080/api/v1/admin/session/transition \
  -H "Authorization: Bearer $KEY_OPS" \
  -H 'Content-Type: application/json' \
  -d '{"to_state": "CONTINUOUS"}'

A successful transition returns 200 with status: "APPLIED" and a command_id. Requesting a state the engine cannot apply because sessions are disabled returns 409 TRANSITION_REJECTED with the engine's reason as message. A transition that is simply not legal from the current phase (for example CONTINUOUS when the market is already CONTINUOUS) gets no answer from the engine at all: the call waits about three seconds and ends with 503 ENGINE_TIMEOUT. Check GET /api/v1/session first, as for the console.

Look at cross-gateway order visibility:

curl -H "Authorization: Bearer $KEY_OPS" \
  "http://127.0.0.1:8080/api/v1/admin/orders?symbol=AAPL"

This is the only endpoint that shows every gateway's resting orders in one call; a trading key never sees this. Note the retention_sec field in the reply — filled/cancelled/expired orders are evicted from this view after order_retention_sec (default one hour, configurable per api_gateways instance), while resting orders are never evicted regardless of age.

Fetch one order's full cross-gateway lifecycle from the audit trail:

curl -i -H "Authorization: Bearer $KEY_OPS" \
  http://127.0.0.1:8080/api/v1/admin/orders/ORDER_ID/audit

This endpoint is optional: it depends on a separate pm-audit index that this gateway only reads, never writes. If pm-audit has not built one, expect 503 AUDIT_INDEX_UNAVAILABLE — every other admin endpoint keeps working normally.

Finally, cancel resting exposure with the kill-switch family:

curl -X POST http://127.0.0.1:8080/api/v1/admin/symbols/AAPL/cancel-orders \
  -H "Authorization: Bearer $KEY_OPS" \
  -H 'Content-Type: application/json' \
  -d '{"reason": "training exercise"}'

curl -X POST http://127.0.0.1:8080/api/v1/admin/gateways/TRADER01/kill-switch \
  -H "Authorization: Bearer $KEY_OPS" \
  -H 'Content-Type: application/json' \
  -d '{"reason": "training exercise"}'

Expected behavior:

  • a trading key gets 403 ROLE_DENIED on every /admin/* path
  • halting a symbol the engine does not know returns 409 HALT_REJECTED with message: "Unknown symbol: ..." — the engine refused, your role is fine
  • /admin/session/transition blocks for the engine's verdict (applied, or 409 when sessions are disabled; an illegal next phase ends in 503 ENGINE_TIMEOUT)
  • /admin/orders returns orders from every gateway, not just OPS01's
  • /admin/orders/{order_id}/audit either returns the full lifecycle or a clear 503 AUDIT_INDEX_UNAVAILABLE if no audit index is deployed

Checkpoint: you can explain why role resolution happens against the engine rather than being a property of the API key itself.

Exercise 11: Configure Multiple Logical API Gateways

Generate two API gateway process configs, one for desk trading and one for algorithmic trading:

pm-config-gen \
  --symbols AAPL MSFT \
  --participants TRADER01 ALGO01 OPS01:ADMIN \
  --api-gateway-instance desk:TRADER01:8080 \
  --api-gateway-instance algos:ALGO01:8081 \
  --seed 20260624 \
  --output engine_config.yaml --force

Start each named process in a separate terminal:

pm-api-gwy --instance desk
pm-api-gwy --instance algos

Try an invalid duplicate assignment:

pm-config-gen \
  --symbols AAPL \
  --participants TRADER01 \
  --api-gateway-instance desk:TRADER01 \
  --api-gateway-instance algos:TRADER01 \
  --dry-run

Expected behavior:

  • desk listens on 8080 and owns TRADER01
  • algos listens on 8081 and owns ALGO01
  • duplicate non-null gateway_id assignment is rejected before runtime

Checkpoint: you can run two API gateway processes and explain why a write-capable gateway ID is globally unique across them.

Exercise 12: Write a Python CLI Client for LIMIT Order Entry

Use the ApiGatewayClient library from docs/examples/REST/python to write a small script that submits a LIMIT order and prints the engine response.

import argparse, json, os, sys
sys.path.insert(0, os.path.join(os.environ.get("EX", "docs/examples"), "REST/python"))
from api_gateway_client import ApiGatewayClient

parser = argparse.ArgumentParser()
parser.add_argument("--side",   required=True, choices=["BUY", "SELL"])
parser.add_argument("--symbol", required=True)
parser.add_argument("--qty",    required=True, type=int)
parser.add_argument("--price",  required=True, type=float)
parser.add_argument("--wait-ack", action="store_true")
args = parser.parse_args()

client = ApiGatewayClient(
    os.environ.get("EDUMATCHER_API_URL", "http://127.0.0.1:8080"),
    os.environ.get("EDUMATCHER_API_KEY", "key-trader-demo"),
)
path = "/api/v1/orders" + ("?wait=ack" if args.wait_ack else "")
result = client.post_json(path, {
    "symbol": args.symbol.upper(),
    "side":   args.side,
    "order_type": "LIMIT",
    "quantity": args.qty,
    "price":  args.price,
})
print(json.dumps(result, indent=2))

Save it as limit_order.py, set EDUMATCHER_API_KEY to a trading key (export EDUMATCHER_API_KEY=$KEY_TRADER; the built-in default is only a placeholder) and run it from the repo root (or from anywhere with EX set as above):

python3 limit_order.py --side BUY --symbol AAPL --qty 100 --price 140.00
python3 limit_order.py --side BUY --symbol AAPL --qty 100 --price 140.00 --wait-ack

Expected behavior:

  • without --wait-ack: status is PENDING and event is null
  • with --wait-ack: status is ACKED and event contains the engine response
  • sending a read-only key returns 403 READ_ONLY
  • omitting --price is stopped by the script's own argument parser (error: the following arguments are required: --price) before any request is sent; to see the gateway's 400 VALIDATION for a LIMIT order without price, post the JSON body with curl and leave price out

A fully documented MARKET order version that follows the same pattern is available at docs/examples/REST/python/submit_market_order.py.

Checkpoint: your script prints order_id and status from a live gateway.

Support Libraries and Example Clients

Reference examples used in this training chapter:

  • docs/examples/REST/python
  • docs/examples/REST/c

Use these examples as small integration clients when building course labs, smoke tests, or external adapter prototypes.

Summary

You can now:

  • Generate one or more api_gateways process configs with trading, read-only, and ADMIN-resolved bearer credentials.
  • Start and reach pm-api-gwy over REST, Swagger, and WebSocket.
  • Submit, cancel, and observe orders through REST and private WebSocket events.
  • Query the reference-data, history, and admin REST surfaces, and explain what each returns and who is allowed to call it.
  • Explain why a write-capable ALF gateway_id must be unique across every configured api_gateways process, and why ADMIN access is resolved from the engine rather than stored on the API key.

See REST and WebSocket API Reference for the full normative contract behind every endpoint used in this chapter.

Reflection

If two API gateway processes both listed the same gateway_id as a read-only (gateway_id: null) credential, would that be a problem? Why does the constraint only apply to non-null gateway_id values?

GET /api/v1/status returns a different gateway_role for the same running engine depending on which key you authenticate with. Where does that role actually come from, and what would go wrong if the API gateway instead trusted a role embedded directly in the key?

Handoff for Chapter 26

Before starting 26 — Market Index (pm-index), you can stop pm-api-gwy; it is not required there. Keep pm-engine and pm-stats running if you want to reuse the same session, or start fresh — Chapter 26 generates its own config with pm-config-gen.