PyBook 12: The HTTP API
What this notebook proves: "the HTTP API + webhooks: the Plaid-shaped
endpoints, the batch endpoint, /coverage, signed webhooks with
retries. Done when: webhooks are signed, durable and replayable."
"Library first, service second" is not a slogan about ordering. It is a
statement about dependency direction. api/ is a thin shell that takes
a Deps bundle of already-built objects and translates HTTP into calls the
library was going to expose anyway. Nothing in api/ knows how to price an
asset, and nothing outside api/ is allowed to import FastAPI (the layering
gate enforces it).
The whole book runs in-process: TestClient speaks ASGI directly and the
webhook deliverer runs over httpx.MockTransport. No socket is opened.
from fastapi.testclient import TestClient
from auradefi.api.app import create_app
from auradefi.api.deps import Deps
from auradefi.chains.registry import ChainRegistry
from auradefi.clock import FrozenClock
from auradefi.ledger.backends.memory import MemoryLedger
from auradefi.tenancy.audit import AuditLog
from auradefi.tenancy.keys import ApiKeyStore
from auradefi.tenancy.models import Environment, Scope
from auradefi.tenancy.quota import QuotaCounter, QuotaLimits
from auradefi.tenancy.store import TenancyStore
from auradefi.tenancy.tokens import RevocationSet
from auradefi.webhooks.deliver import WebhookStore
NOW = 1_754_000_000_000
clock = FrozenClock(NOW)
tenancy = TenancyStore()
organisation = tenancy.create_organisation("acme", clock)
project = tenancy.create_project(organisation.id, "main", Environment.LIVE, clock)
secrets_vault = {project.id: project.signing_secret}
webhooks = WebhookStore()
chains = ChainRegistry()
deps = Deps(
tenancy=tenancy,
keys=ApiKeyStore(),
quota=QuotaCounter(QuotaLimits(1_000, 10_000, 100_000), clock),
audit=AuditLog(),
revocations=RevocationSet(),
ledger=MemoryLedger(),
webhooks=webhooks,
chains=chains,
clock=clock,
signing_secret_for=secrets_vault.get,
capabilities={"eip155:1": frozenset({"balances", "transactions", "prices"})},
)
client = TestClient(create_app(deps))
schema = client.get("/openapi.json").json()
assert schema["info"]["title"] == "auradefi"
assert "/batch/holdings" not in schema["paths"] # unbound capability => no route at all
for path in sorted(schema["paths"]):
for method in sorted(schema["paths"][path]):
print(f"{method.upper():<6} {path}")
POST /auth/revoke
POST /auth/token
GET /connections
POST /connections
GET /connections/{connection_id}
GET /coverage
GET /crypto/sync
GET /users
GET /users/me
GET /webhooks/dead_letter
GET /webhooks/deliveries
POST /webhooks/deliveries/{delivery_id}/replay
GET /webhooks/endpoints
POST /webhooks/endpoints
/opt/hostedtoolcache/Python/3.12.13/x64/lib/python3.12/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
Two credentials, two audiences
- an API key (
adk_live_…), server-side only, scoped, long-lived;
- a user token, minted by the key, short-lived, scoped to one end
user, safe to hand to a browser.
POST /auth/token is the authEndpoint from
06_tenancy exposed over HTTP: one opaque
external_user_id in, one bearer token out. Every mint is audited with the
key id and the caller's IP, and every response carries the nine rate-limit
headers (three windows x limit/remaining/reset).
key, plaintext = deps.keys.issue(
project.id, Environment.LIVE,
(Scope.USERS_ADMIN, Scope.ACCOUNTS_READ, Scope.ACCOUNTS_WRITE), clock,
)
assert plaintext.startswith("adk_live_") and len(plaintext) == 57
key_headers = {"Authorization": f"Bearer {plaintext}"}
minted = client.post(
"/auth/token",
json={"external_user_id": "host-user-7"},
headers={**key_headers, "X-Forwarded-For": "198.51.100.9"},
)
assert minted.status_code == 200
assert list(minted.json()) == ["token"] # exactly one key: nothing leaks
token_headers = {"Authorization": f"Bearer {minted.json()['token']}"}
(entry,) = deps.audit.entries(project.id)
# The audited IP is the SOCKET PEER, not the X-Forwarded-For the request
# carries: trusted_proxy_hops defaults to 0, so no proxy is trusted and a
# caller cannot choose the address its own permanent audit row records
# (0.1.1 #30). entry.ip_source says which source the value came from.
assert (entry.seq, entry.event, entry.key_id, entry.ip) == (1, "token.minted", key.id, "testclient")
limits = {name.lower(): value for name, value in minted.headers.items()
if name.lower().startswith("x-ratelimit")}
assert len(limits) == 9 # 3 windows x limit/remaining/reset
assert limits["x-ratelimit-limit-second"] == "1000"
assert limits["x-ratelimit-remaining-second"] == "999"
print("audited:", entry.event, "by", entry.key_id, "from", entry.ip)
print("rate limit headers:", {k: v for k, v in sorted(limits.items()) if k.endswith("second")})
audited: token.minted by key_10d096175c4562dc from testclient
rate limit headers: {'x-ratelimit-limit-second': '1000', 'x-ratelimit-remaining-second': '999', 'x-ratelimit-reset-second': '1754000001000'}
Connections: and idempotence you can act on
POST /connections is authorised by the user token, so a connection can
only ever be created for the user the token names. A repeat of the same
descriptor is a 409 that carries existing_connection_id, which is what
lets a client retry safely: it gets the id it would have created.
The ledger tenant key is pinned to the caller's derived usr_ id: it
already hashes project_id | external_user_id, so no route can widen the
scope by passing something coarser.
ADDRESS = "0xAAAAaaaaAAAAaaaaAAAAaaaaAAAAaaaaAAAAaaaa"
created = client.post("/connections", json={"kind": "address", "descriptor": ADDRESS}, headers=token_headers)
assert created.status_code == 201
connection_id = created.json()["id"]
assert connection_id.startswith("conn_")
conflict = client.post(
"/connections", json={"kind": "address", "descriptor": ADDRESS.lower()}, headers=token_headers
)
assert conflict.status_code == 409
assert conflict.json()["error"]["existing_connection_id"] == connection_id
assert conflict.json()["error"]["type"] == "ConflictError"
me = client.get("/users/me", headers=token_headers).json()
assert me["id"].startswith("usr_")
print("created", connection_id, "| repost ->", conflict.status_code, conflict.json()["error"]["message"])
print("caller:", me)
created conn_b955659e2e55d4ca | repost -> 409 connection already exists: 'conn_b955659e2e55d4ca'
caller: {'id': 'usr_7f859501dc340dcd', 'project_id': 'proj_08ac97861ac0e345', 'external_user_id': 'host-user-7', 'created_at_ms': 1754000000000}
GET /crypto/sync: Plaid's envelope, unchanged
Five keys, always: added, modified, removed, next_cursor,
has_more. Page until has_more is False. modified is
always an empty list by construction, a changed payload re-emits as
added with a bumped sequence, and a reorg is removed + re-added, but
the key exists so a Plaid client that iterates all three arrays keeps
working unmodified.
Inside every entry, rule #2 holds all the way to the wire: raw is a
string.
from auradefi.ledger.models import Direction, Entry, LedgerTransaction, transaction_id
from auradefi.money.quantity import Quantity
from auradefi.tenancy.models import end_user_id
CHAIN, ASSET = "eip155:1", "eip155:1/slip44:60"
tenant = end_user_id(project.id, "host-user-7")
assert tenant == me["id"]
def txn(index):
tx_hash = "0x" + f"{index:02x}" * 32
return LedgerTransaction(
id=transaction_id(CHAIN, tx_hash, "acct_eth"), chain_id=CHAIN, tx_hash=tx_hash,
account_id="acct_eth", block_number=18_000_000 + index,
initiated_at=1_753_000_000_000 + index, confirmed_at=1_753_000_000_500 + index,
entries=(Entry(asset_id=ASSET, quantity=Quantity(index * 10**17, 18), direction=Direction.IN),),
)
deps.ledger.upsert(tenant, [txn(index) for index in (1, 2, 3)])
seen, cursor, pages = [], None, 0
while True:
query = "/crypto/sync?limit=2" + (f"&cursor={cursor}" if cursor else "")
page = client.get(query, headers=token_headers).json()
pages += 1
assert set(page) == {"added", "modified", "removed", "next_cursor", "has_more"}
assert page["modified"] == [] and page["removed"] == []
seen.extend(row["transaction_id"] for row in page["added"])
cursor = page["next_cursor"]
if not page["has_more"]:
break
assert pages == 2 and len(seen) == 3
assert cursor == "00000000000000000003"
sample = page["added"][0]["entries"][0]["quantity"]
assert isinstance(sample["raw"], str) # rule #2, at the boundary
assert sample == {"raw": "300000000000000000", "decimals": 18,
"numeric": "0.3", "float": 0.3}
print(f"{pages} pages, {len(seen)} transactions, final cursor {cursor}")
print("wire quantity:", sample)
2 pages, 3 transactions, final cursor 00000000000000000003
wire quantity: {'raw': '300000000000000000', 'decimals': 18, 'numeric': '0.3', 'float': 0.3}
GET /coverage: the matrix is generated, never written
A known risk: "Docs lie, including your own. Generate the coverage
matrix from live capability checks, never from prose." So /coverage
projects the chain registry against exactly what the host bound into
Deps.capabilities. A chain nobody bound reports all five capabilities
False: an honest under-claim. There is no way to write true into this
endpoint except by binding a capability that exists.
coverage = client.get("/coverage").json() # no auth: it is a public fact
assert coverage["capabilities"] == ["balances", "transactions", "positions", "prices", "xpub"]
rows = {row["chain_id"]: row for row in coverage["chains"]}
assert rows["eip155:1"]["capabilities"] == {
"balances": True, "transactions": True, "positions": False, "prices": True, "xpub": False,
}
assert rows["bip122:000000000019d6689c085ae165831e93"]["capabilities"] == {
"balances": False, "transactions": False, "positions": False, "prices": False, "xpub": False,
}
print(f"{'chain':<40} {'name':<10} " + " ".join(f"{c:<12}" for c in coverage["capabilities"]))
for row in coverage["chains"]:
flags = " ".join(f"{str(row['capabilities'][c]):<12}" for c in coverage["capabilities"])
print(f"{row['chain_id']:<40} {row['name']:<10} {flags}")
chain name balances transactions positions prices xpub
bip122:000000000019d6689c085ae165831e93 Bitcoin False False False False False
eip155:1 Ethereum True True False True False
eip155:137 Polygon False False False False False
eip155:8453 Base False False False False False
solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp Solana False False False False False
Webhooks: signed, durable, replayable
The gate, in three properties:
- signed, HMAC-SHA256 over
timestamp.body, in an
X-Auradefi-Signature header, with the endpoint secret returned exactly
once at registration and never listed again;
- durable, a delivery is a row with attempts and a next-attempt time,
following a pinned retry schedule (0, 1m, 5m, 30m, 2h, 24h) into a dead
letter queue;
- replayable, a dead-lettered delivery can be re-armed as a new
delivery; the original row is never mutated, so the audit trail survives.
import httpx
from auradefi.webhooks.deliver import Deliverer
from auradefi.webhooks.sign import verify_signature
HOOK_URL = "https://hooks.example.com/inbox"
registered = client.post("/webhooks/endpoints", json={"url": HOOK_URL}, headers=key_headers)
assert registered.status_code == 201
secret = registered.json()["secret"]
assert len(secret) == 64
listed = client.get("/webhooks/endpoints", headers=key_headers).json()["endpoints"][0]
assert "secret" not in listed # shown once, at creation, and never again
print("endpoint", listed["id"], "registered; secret shown once")
clock.advance(1_000)
queued_at = clock.now_ms()
client.post("/connections", json={"kind": "address", "descriptor": "0x" + "bb" * 20}, headers=token_headers)
pending = client.get("/webhooks/deliveries", headers=key_headers).json()
assert pending["count"] == 1
assert pending["deliveries"][0]["event_name"] == "connection.created"
assert pending["deliveries"][0]["status"] == "pending"
print("queued:", pending["deliveries"][0]["event_name"], "at", pending["deliveries"][0]["next_attempt_at_ms"])
endpoint whe_4968735032958cee registered; secret shown once
queued: connection.created at 1754000001000
class Recorder:
"A MockTransport handler: records every request, answers one status."
def __init__(self, status_code):
self.status_code, self.requests = status_code, []
def __call__(self, request):
self.requests.append(request)
return httpx.Response(self.status_code)
recorder = Recorder(200)
deliverer = Deliverer(webhooks, httpx.Client(transport=httpx.MockTransport(recorder)))
(delivered,) = deliverer.tick(queued_at)
assert len(recorder.requests) == 1
sent = recorder.requests[0]
assert sent.method == "POST" and str(sent.url) == HOOK_URL
assert sent.headers["X-Auradefi-Timestamp"] == str(queued_at)
# The receiver's side of the contract, run for real:
verify_signature(secret, queued_at, sent.content.decode("utf-8"),
sent.headers["X-Auradefi-Signature"], queued_at)
assert delivered.status.value == "delivered"
assert deliverer.tick(queued_at) == () # a delivered row is never re-sent
print("signature:", sent.headers["X-Auradefi-Signature"][:32] + "…", "-> verified")
print("body:", sent.content.decode("utf-8")[:110] + "…")
signature: v1=7d5c1e044897d58af7620455225f1… -> verified
body: {"created_at_ms":1754000001000,"data":{"connection_id":"conn_1b93ae01f06362c6","descriptor":"0xbbbbbbbbbbbbbbb…
from auradefi.webhooks.models import RETRY_SCHEDULE_MS
# A fresh event against a receiver that is down: the pinned schedule, then
# the dead letter queue.
clock.advance(1_000)
born_at = clock.now_ms()
client.post("/connections", json={"kind": "address", "descriptor": "0x" + "cc" * 20}, headers=token_headers)
failing_recorder = Recorder(500)
failing = Deliverer(webhooks, httpx.Client(transport=httpx.MockTransport(failing_recorder)))
for attempt, offset in enumerate(RETRY_SCHEDULE_MS):
(row,) = failing.tick(born_at + offset)
assert row.attempts == attempt + 1 and row.last_status_code == 500
assert len(failing_recorder.requests) == 6
assert row.status.value == "dead_letter" and row.next_attempt_at_ms is None
letters = client.get("/webhooks/dead_letter", headers=key_headers).json()
assert letters["count"] == 1 and letters["deliveries"][0]["attempts"] == 6
print("retry schedule (ms):", RETRY_SCHEDULE_MS, "-> dead letter after", row.attempts, "attempts")
# Replay: a NEW delivery row, the original untouched.
clock.advance(1_000)
replayed = client.post(f"/webhooks/deliveries/{row.id}/replay", headers=key_headers)
assert replayed.status_code == 202
assert replayed.json()["id"] != row.id and replayed.json()["replay_ordinal"] == 1
assert replayed.json()["status"] == "pending"
healthy_recorder = Recorder(200)
(settled,) = Deliverer(webhooks, httpx.Client(transport=httpx.MockTransport(healthy_recorder))).tick(clock.now_ms())
assert settled.id == replayed.json()["id"] and settled.status.value == "delivered"
assert [d["status"] for d in client.get("/webhooks/deliveries", headers=key_headers).json()["deliveries"]] == [
"delivered", "dead_letter", "delivered",
]
print("replayed", row.id, "->", settled.id, settled.status.value)
retry schedule (ms): (0, 60000, 300000, 1800000, 7200000, 86400000) -> dead letter after 6 attempts
replayed dlv_8de104ed55d604a9 -> dlv_bdf1d4ab68e8fc96 delivered
Errors: one taxonomy, one envelope
Every AuradefiError maps to a status through a single table, and every
failure body has exactly one top-level key. A client parses one shape,
always: {"error": {"type", "message", "status", …}}.
cases = [
("no credential", client.get("/crypto/sync")),
("user token where a key is required", client.get("/webhooks/endpoints", headers=token_headers)),
("key where a user token is required", client.get("/crypto/sync", headers=key_headers)),
("malformed body", client.post("/connections", json={"kind": "address"}, headers=token_headers)),
("bad cursor", client.get("/crypto/sync?cursor=nonsense", headers=token_headers)),
]
for label, response in cases:
body = response.json()
assert set(body) == {"error"}
assert body["error"]["status"] == response.status_code
print(f"{response.status_code} {label:<36} {body['error']['type']}: {body['error']['message'][:52]}")
401 no credential AuthError: token failed authentication
401 user token where a key is required AuthError: api key failed authentication
401 key where a user token is required AuthError: token failed authentication
422 malformed body ValidationError: request validation failed
422 bad cursor CursorError: cursor token must be exactly 20 ASCII digits: 'nonse
Honest edges
- The service is a shell you wire.
create_app(deps) needs a Deps
bundle; there is no batteries-included main.py, no config file, and no
process manager. That is deliberate. The library is the product.
POST /batch/holdings mounts only when the host binds
deps.holdings; unbound, the path genuinely 404s rather than advertising
a capability the deployment cannot perform (rule #10).
- Stores in this book are in-memory: tenancy, keys, quota, audit and
webhooks all live in the process. Only the ledger has a SQL backend
(
09_embedding).
- There is no OpenAPI-first contract test, no pagination on
/webhooks/deliveries, and no async delivery worker. Deliverer.tick
is called by the host's scheduler.
That is the last book. The capability table in
README.md says the same thing in one page, and every
row of it links back here.