v0.2 DX / SDK / Docs / Community plan — crypto-processing-api¶
Grounded in the code as it exists at v0.1.0. All paths below are repo-relative from E:\codespace\_claude_code\_swift-punk-projects\crypto-processing-api.
0. Ground truth from the code that shapes every decision below¶
These are facts I verified by reading the source, and each one constrains the design:
- Every router returns untyped dicts. All endpoints declare
response_model=Noneand build responses via hand-rolled serializers (serialize_depositinsrc/crypto_processing_api/api/deposits.py:78,serialize_withdrawalinapi/withdrawals.py:51, ad-hoc dicts inapi/balances.py,api/admin.py,api/health.py). Consequence: the current/openapi.jsonhas request schemas but no response schemas. Any generated SDK today would type every response asAny. This makes "OpenAPI hardening" a hard prerequisite (Workstream A) before any codegen. - No
operation_ids are set (main.py:73-94app factory is bare). FastAPI's defaults produce method names likecreate_deposit_v1_deposits_post— ugly in a generated SDK. Additive fix. - The outbound signature scheme is deliberately Stripe's (
src/crypto_processing_api/core/signing.py:45-81):X-CPA-Signature: t=<unix>,v1=<hex hmac-sha256("{t}.{body}")>, 300 s replay window, constant-time compare.verify_platform_signaturealready exists server-side "for the tests and the docs example" — it is the reference implementation both SDKs must port. The signed bytes are produced once inworkers/outbound_delivery.py:65-79(event_body:{"id": "evt_...", "type", "created_at", "data"}, compact separators, sorted keys). - Event types are a closed set of 8 in
src/crypto_processing_api/services/events.py:deposit.detected|settled|review_required|expired,withdrawal.pending_approval|broadcast|completed|failed. SDKs can ship them as constants/unions. - Idempotency contract (
src/crypto_processing_api/api/middleware.py:222-273):Idempotency-Keyheader required on mutating endpoints; 400 if missing, 409 +Retry-Afterif in flight, 422 on body mismatch, stale takeover after 60 s, and docs/integrating.md screams "retry with the same key." This is exactly the ergonomics an SDK must automate: auto-mint a UUID per logical call, pin it across retries, retry 503/409 honoringRetry-After. - Amounts are decimal strings everywhere (
from_units), and the two request fieldsexpected_amount/amountare integer-smallest-unit strings. SDK types must bestr(+ helper converters), nevernumber/float. - Auth:
Authorization: Bearer cpk_live_…, scopesreadwrite/admin(api/middleware.py:130-170). release.ymlalready hasid-token: write— the exact permission PyPI trusted publishing and npm provenance need; extending it is additive.- Community surface is nearly empty:
.github/contains onlyworkflows/. No issue templates, no PR template, no CODE_OF_CONDUCT, no ROADMAP.SECURITY.mdand a strongCONTRIBUTING.md(with the 9-invariant money-path rule at lines 54-74) already exist and should be referenced, not duplicated. docs/is already a docs site in waiting: 9 operator/integrator docs + 7 design-record docs, all plain markdown with relative links — MkDocs-compatible with almost no editing.
Workstream A — OpenAPI hardening (prerequisite, ships first)¶
Everything downstream (SDKs, API reference page, drift gates) consumes one artifact: a committed, deterministic openapi.json.
Changes (all additive, no wire-format change, so no versioning problem):
- New module
src/crypto_processing_api/api/schemas.py— Pydantic response models mirroring the existing serializers exactly:DepositResponse,DepositPaymentResponse,DepositListResponse,WithdrawalResponse,WithdrawalListResponse,BalancesResponse,TransactionsResponse,AssetsResponse,AddressHistoryResponse,HealthResponse,ReadyResponse, plus the admin queue/resolve/requeue shapes fromapi/admin.py. Field-for-field copies of whatserialize_*emits today — write a test that assertsModel.model_validate(serialize_deposit(...))round-trips, so the models cannot drift from the serializers. - Annotate routers with
response_model=(or convert serializers to return the models; either way keep JSON identical — key order and string amounts included) and setoperation_idon every route:createDeposit,getDeposit,listUserDeposits,createWithdrawal,getWithdrawal,listUserWithdrawals,getUserBalances,getUserTransactions,listAssets,getAddressHistory, plusadmin*names. Also addopenapi_tagsmetadata and a top-leveldescriptionincreate_app()(main.py:77). - Document the
Idempotency-Keyheader in OpenAPI via a sharedHeader(...)parameter on the two POSTs, and the error envelope ({"detail": ...}+ the structured pool-exhaustioncode) as documented responses. scripts/export_openapi.py—json.dump(create_app().openapi(), sort_keys=True, indent=2)todocs/reference/openapi.json. No server, no DB needed (app factory only touches settings at import; verify with defaultedSettings).- CI drift gate — new job in
.github/workflows/ci.yml: run the export,git diff --exit-code docs/reference/openapi.json. A route change that isn't reflected in the committed spec fails CI. This one job is what makes SDK regeneration "a CI step, not a chore."
Maintenance cost: near zero after landing — the drift gate reduces maintenance by making spec staleness impossible.
Workstream B — SDKs (Python on PyPI, TypeScript on npm)¶
Decision: generated core + thin handwritten ergonomics layer, both languages¶
Rationale, verified against the 2026 ecosystem:
- TypeScript:
@hey-api/openapi-ts. Actively developed through 2026 (v0.99, releases monthly, 10-30x perf improvements landed April 2026), used in production by Vercel and PayPal, generates a typed fetch-based SDK plus standalone types from OpenAPI 3.1 (which FastAPI 0.141 emits). It is the clear community default overopenapi-generator's Java toolchain. - Python:
openapi-python-client. Still the maintained community generator in 2026: httpx-based, sync + async clients, typed dataclass models, shipspy.typed. The API surface here (~15 endpoints) is well inside what it handles cleanly. - Rejected: Fern / Stainless / Speakeasy. Better output, but commercial services with free tiers that change; a one-maintainer MIT project should not have its release pipeline depend on a vendor account.
- Rejected: fully handwritten SDKs. Handwritten clients drift silently when a route changes. Generated cores + the Workstream A drift gate mean a route change mechanically forces an SDK regen in the same PR.
- Why a handwritten layer at all: codegen cannot produce the two things integrators actually need help with — webhook signature verification and idempotency-key discipline. Those are small, stable, and worth owning.
Sources: Speakeasy's Python OSS generator comparison, hey-api on npm, hey-api 2026 releases, TS client generation comparison, Python SDK from OpenAPI in 2026, FastAPI SDK generation docs.
Layout (monorepo, no submodules)¶
sdks/
python/ # PyPI: crypto-processing-client
pyproject.toml
crypto_processing_client/
_generated/ # openapi-python-client output, never hand-edited
client.py # facade: auto Idempotency-Key, Retry-After-aware retry
webhooks.py # verify_signature(), event type constants, VerificationError
amounts.py # str <-> Decimal helpers (never float)
tests/ # incl. cross-language signature vectors
typescript/ # npm: crypto-processing-client
package.json
openapi-ts.config.ts
src/
generated/ # hey-api output
client.ts # facade (idempotency, retries)
webhooks.ts # verifySignature() via node:crypto timingSafeEqual
index.ts
test/
sdks/signature-vectors.json # shared test vectors: secret, timestamp, body, header
The two mandatory ergonomics features¶
- Webhook verification helper — a direct port of
verify_platform_signature(core/signing.py:58-81): parset=,v1=, enforce the 300 s window, constant-time compare (hmac.compare_digest/crypto.timingSafeEqual), operate on raw body bytes (the doc trap atdocs/integrating.md:446-452). Plus aparse_event(raw, header, secret)that verifies then returns a typed{id, type, created_at, data}union over the 8 event types. Cross-language correctness is enforced bysdks/signature-vectors.json: vectors generated once by the server's ownsign_platform_payload, asserted by the server test suite and both SDK test suites — the three implementations can never disagree silently. - Idempotency ergonomics —
client.deposits.create(...)mints a UUIDv4Idempotency-Keyautomatically, accepts an explicitidempotency_key=for caller-controlled logical operations, and the built-in retry (on 503 and 409, honoringRetry-After, bounded attempts) always reuses the same key — encoding the "retry with the same key, do not generate a new one" rule fromdocs/integrating.md:399so integrators cannot get it wrong by default.
Regeneration and publishing (CI, not chore)¶
- New
.github/workflows/sdk.yml: - On PR: regenerate both SDKs from
docs/reference/openapi.json, fail on diff (second drift gate), run both SDK test suites (including signature vectors). - On
v*tag (or as jobs appended torelease.yml): build and publish. - PyPI: trusted publishing (OIDC) —
pypa/gh-action-pypi-publishwithid-token: writeand a GitHubpypienvironment; no long-lived token stored. Register the publisher on PyPI once againstOliverD25/crypto-processing-api+sdk.yml. - npm: provenance —
npm publish --provenance --access publicwithid-token: write; the repo is public so provenance attestation works. OneNPM_TOKENsecret is still required (npm's OIDC-only publishing is granular-token + provenance today); scope it to the single package. - Versioning: SDKs share the repo version. One
v0.2.0tag releases the image and both SDKs as0.2.0. Policy statement (goes in the versioning doc, Workstream E): SDK minor tracks API minor —client 0.2.xsupportsserver 0.2.y; SDK-only fixes bump patch; the server never bumps solely for an SDK fix (skip publish if unchanged). For one maintainer, one version number is the only scheme that stays true without effort.
Maintenance cost: regeneration is automatic; the handwritten surface is ~300 lines per language of code that changes only when the signature scheme or idempotency contract changes (i.e., almost never, and loudly). Publishing is tag-triggered with no secrets to rotate except one npm token.
Workstream C — Example integration app (examples/platform-demo/)¶
Stack: single-file FastAPI + Jinja2 + HTMX, using the Python SDK. Reasons: same language as the codebase (readers are already in Python-mode); a webhook receiver needs a server, which rules out a static page; HTMX keeps it to one app.py plus templates with zero build step; and it dog-foods crypto-processing-client, so the example doubles as an SDK acceptance test. Target: app.py under ~350 lines, written to be read top-to-bottom like a tutorial.
What it demonstrates, end-to-end against the regtest stack:
- "Log in" as a fake user (a text box, users are just
external_user_ids). - Create a BTC deposit → render the address +
checkout_link; pollGET /v1/deposits/{id}and show thepending → confirming → settledtransitions exactly asdocs/integrating.md:502-511prescribes. - Show balances from
GET /v1/users/{id}/balances— with a visible comment: this app stores no balances; the API is the source of truth. - Request a withdrawal → show
pending_approvalvs auto-approved, thenbroadcast+ txid. /platform-webhookendpoint implementing the 5-step contract fromdocs/integrating.md:456-462: verify (SDK helper) → dedup onevt_id → 200 immediately → re-read the resource → act on the GET. Each step is a numbered comment.
Files:
examples/platform-demo/
README.md # the tutorial: what to run, what to watch, what each step proves
app.py
templates/ # base.html, index.html, fragments
Dockerfile
Wiring: one compose profile. Add an example service to deploy/docker-compose.regtest.yml under profiles: ["example"], on the same network, with PLATFORM_WEBHOOK_URL=http://example:8080/platform-webhook and PLATFORM_WEBHOOK_SECRET set for the api/worker services so outbound delivery actually fires. docker compose -f deploy/docker-compose.regtest.yml --profile example up -d and scripts/dev/mine.sh complete the loop. The existing drills are untouched (profile is opt-in).
Maintenance cost: low — it compiles against the published SDK, so SDK CI catches breakage; it has no tests of its own beyond "container builds" in CI.
Workstream D — Docs site¶
Tooling: MkDocs-format markdown, built with pinned Material for MkDocs; Zensical-ready by construction¶
The 2026 wrinkle: Material for MkDocs entered maintenance mode and reaches EOL on November 5, 2026, with Zensical announced as its successor — and Zensical natively reads mkdocs.yml (EOL notice, honest 2026 review). The low-maintenance play for one maintainer:
- Author everything as plain markdown + a boring
mkdocs.yml: no custom theme overrides, no exotic plugins. That keeps the eventualpip install zensicalmigration a config-level change. - Build with pinned
mkdocs-materialtoday (known-good, still receiving security fixes through Nov 2026; the generated static site does not stop working at EOL). Re-evaluate Zensical at v0.3 — ifmkdocs buildswaps tozensical buildcleanly, switch then. - No
mike/ no versioned docs. Pre-1.0, a version selector doubles publish complexity for a project with one deployed version per operator. Instead: a one-line admonition on the landing page — "These docs trackmain. For the docs matching your deployed release, browsedocs/at your release tag." Revisit at 1.0.
Information architecture (what moves vs. what is written new)¶
docs/
index.md NEW — positioning + pitch (shares source with README top-fold)
getting-started/
quickstart.md NEW (thin) — regtest in 10 minutes, lifted from README + smoke test
deployment.md MOVED from docs/deployment.md
btcpay-setup.md MOVED
integrating/
index.md MOVED from docs/integrating.md (kept whole — it reads well as one page)
sdks.md NEW — install + 20-line example per language, webhook verify in both
example-app.md NEW (thin) — points at examples/platform-demo
operating/
security.md, backups.md, MOVED
runbook-*.md (3 files) MOVED
extending/
adding-an-asset.md NEW — the formal asset contract (authored by the asset workstream;
this IA reserves its home)
reference/
api.md MOVED from docs/api.md (the human-written lookup table stays)
openapi.md NEW — one page embedding Scalar API Reference via CDN script tag
openapi.json Workstream A artifact (committed)
webhooks.md NEW — the 8 event types, payload shapes, signature scheme, extracted
from integrating.md + services/events.py
configuration.md NEW, GENERATED — script renders every field of Settings
(src/crypto_processing_api/config.py) with type/default/constraint
into a table; CI drift-gates it like openapi.json. Kills the
.env.example-vs-code drift class permanently.
versioning.md NEW — release cadence, semver, deprecation policy (Workstream E)
design/ MOVED as-is — the design record, prominently linked as
"why it is built this way", with a banner: historical record, not reformatted
Keep git history by using git mv; add a link-check step (mkdocs build --strict catches internal link breakage) so the moves cannot silently 404.
API reference generation: no server-side plugin — reference/openapi.md embeds Scalar's CDN script pointed at the committed ./openapi.json. Zero build-time dependency, always in sync with the drift-gated spec. docs/api.md remains the curated human version (it contains judgment the spec can't: "expected_amount is load-bearing for USDT").
Publish job¶
New .github/workflows/docs.yml: on PR touching docs/**/mkdocs.yml → mkdocs build --strict; on push to main → build + actions/upload-pages-artifact + actions/deploy-pages (GitHub Pages via Actions, pages: write + id-token: write). Enable Pages once in repo settings.
Maintenance cost: the site is the existing docs, moved; only sdks.md, webhooks.md, index.md, and quickstart are genuinely new prose. Two generated pages (openapi, configuration) are drift-gated, not maintained. Theme risk is contained by the no-customization rule.
Workstream E — GitHub community kit¶
Files:
.github/
ISSUE_TEMPLATE/
bug.yml # version, asset, BTCPay version, redaction warning ("no keys, no addresses
# you care about"), what the reconciliation sweep said
feature.yml # includes "which ledger invariant does this touch, if any?"
operator-report.yml # UNUSUAL BUT EARNED: CONTRIBUTING.md:117 explicitly asks for "an
# operator's account of running it" — make that a first-class template
# (what alerted, what was noise, what the runbooks got wrong)
config.yml # blank_issues_enabled: false; contact links → SECURITY.md for vulns,
# Discussions Q&A for questions
PULL_REQUEST_TEMPLATE.md # checkbox: "touches ledger/ or services/?" → required section listing
# which of the 9 invariants (CONTRIBUTING.md:58-74) still hold and why —
# the template makes the existing rule mechanical, with the invariants
# inlined so authors don't tab away
dependabot.yml # pip + github-actions + npm (sdks/), monthly, grouped — deliberate
# cadence for a pinned-dependency money project
CODE_OF_CONDUCT.md # Contributor Covenant 2.1, enforcement contact = muzexp@gmail.com
ROADMAP.md # v0.2 workstreams (asset contract, robustness, DX) with checkboxes;
# a "not planned" list (Lightning, multi-tenancy, hosted service) —
# saying no in writing prevents recurring issues
docs/reference/versioning.md # release + deprecation policy (below)
Discussions categories: Announcements (maintainer-only posts), Q&A — Integration help, Operators (running it in production; feeds the operator-report loop), Asset proposals (the CONTRIBUTING "more assets" invitation, pointed at the v0.2 asset contract), Ideas.
Release cadence + versioning policy (the actual text, condensed):
- Pre-1.0 semver: minor releases may change behavior, with a "Breaking / Migration" section in CHANGELOG.md mandatory (the release.yml awk extraction at lines 84-97 already enforces a changelog section per tag — keep leaning on that). Patch releases never require operator action beyond pull + restart.
- API deprecation: an endpoint or field is marked deprecated in OpenAPI + docs for at least one minor before removal; removal is a minor with a migration note. No breaking change to amount encoding or the signature scheme without a v2 signature version (v1= was chosen precisely so v2= can coexist).
- Migrations: alembic-only, always forward, every release upgradeable from the previous release (test: CI job upgrading a 0.1.0-schema database — belongs to the robustness workstream, referenced here).
- Cadence: minor roughly quarterly, security patches as needed. Honest for one maintainer.
~10 good-first-issues, all verified against the code (label good first issue unless noted):
- Wire the signature-failure spike alert.
AlertCode.WEBHOOK_SIGNATURE_FAILURE_SPIKE(src/crypto_processing_api/alerts/notifier.py:51) andwebhook_signature_failure_threshold(config.py:120) both exist, but nothing counts 401s inapi/webhooks.pyor ever raises it. Self-contained, non-money. list-api-keysCLI command.cli.pyhascreate-api-key/revoke-api-key(lines 146-152) but no way to see key ids, scopes, orlast_used— you cannot revoke what you cannot list.- TypeScript webhook-verification example in
docs/integrating.md(§ line 423 has Python only) — until the SDK ships, and it becomes the SDK doc snippet after. export-openapiCLI/script + CI drift gate (Workstream A item 4-5 — deliberately carved out as an onboarding-sized issue).- Generated configuration reference from the
Settingsmodel (Workstream Dconfiguration.md). - Structured error codes. Only pool exhaustion returns
{"code": ...}(api/deposits.py:154-161, noted indocs/api.md:26); define a small catalog and apply it consistently (additive: keepdetailstrings). - Prometheus
/metricsendpoint (request counts, worker heartbeat ages already computed inapi/health.py:104-126, outbound queue depth). Non-money, operator-visible. - Response-model coverage for admin endpoints (
api/admin.py, 449 lines of dict responses) — a follow-up slice of Workstream A that touches no money logic. - (label
help wanted, money-path) Fee estimate-vs-actual drift journaling — book the drift explicitly tonetwork_fee_expensevia apost_entrycaller, perdocs/design/06-adversarial-critique.mditems #8-#9 (lines 54-60), instead of letting it live in Job C's tolerance. - (label
help wanted, money-path) Per-amount confirmation tiers —docs/runbook-reorg.md:154-155names this as Phase 2 ("Per-amount confirmation tiers are Phase 2; a cheap approximation today is a per-asset delay"). .env.examplecompleteness check againstSettingsfields (subsumed by #5 if the generator also validates — keep whichever lands first).
Maintenance cost: issue forms and templates are write-once. The operator-report template and Discussions category create the only ongoing obligation — reading them — which is the feedback the project explicitly asked for in CONTRIBUTING.
Workstream F — Positioning¶
README top-fold rewrite (first 25 lines, before "What it does"):
- Pitch paragraph (draft): "crypto-processing-api is the accounting layer BTCPay Server doesn't have. Run it next to your own BTCPay node and your platform gets per-user crypto balances, deposits, and withdrawals through a small authenticated JSON API — backed by an append-only double-entry ledger that a webhook cannot corrupt, a reconciliation sweep that doesn't trust the fast path, and a threat model that starts from 'the hot wallet is the loss ceiling.' Self-hosted, single-tenant, MIT."
- Who this is for: a platform that needs per-user custodial BTC/USDT balances (marketplace, game economy, SaaS credits), is willing to self-host BTCPay + one VPS, and wants the money-correctness machinery already built and tested.
- Who this is NOT for (each line preempts a recurring issue): you want non-custodial checkout (use BTCPay alone); you want zero ops and accept custody/KYC/fees by a third party (use a hosted processor); you need Lightning, many assets, or multi-tenancy (not in scope — see ROADMAP "not planned"); you need something audited at scale (v0.x, no external audit —
README.md:84-89already says this honestly; keep saying it).
Comparison table (README + docs/index.md):
| Raw BTCPay Server | Hosted processor (NOWPayments-style) | This project | Build it yourself | |
|---|---|---|---|---|
| Per-user ledger & balances | none — invoices only | their database, their rules | double-entry, append-only, yours | months of subtle work |
| Custody | you | them | you | you |
| Fees | node costs | percentage of volume | node + VPS costs | your time |
| KYC / account risk | none | their policies, can freeze | none | none |
| Withdrawal controls | manual | theirs | holds, velocity caps, approval queue | build them |
| Correctness under crash/retry | n/a | opaque | idempotency + reconciliation sweep, drilled on regtest | the hard part |
| Audit trail | invoice list | CSV export | immutable journal in your PostgreSQL | build it |
| Trust required | your node | a third party with your float | your node + this code (MIT, readable) | your code |
The honest row is custody: this project does not remove custody risk — it gives you the controls and the books; a hosted processor removes the work by taking the float. That framing is consistent with docs/security.md and should be stated verbatim.
Sequencing and dependencies¶
- A (OpenAPI hardening) — blocks B and the docs
reference/pages. Small, additive, do first. - E (community kit) + F (positioning) — no dependencies, cheap, do in parallel with A; they change the repo's first impression immediately.
- B (SDKs) — after A. Signature vectors file lands with the server test that generates it.
- D (docs site) — after A (needs
openapi.json);sdks.mdfinalized after B. - C (example app) — last; consumes the published Python SDK and validates the whole story.
Cross-workstream note: extending/adding-an-asset.md in the docs IA is authored by the asset-contract workstream; this plan only reserves its slot and nav entry. Nothing here touches ledger/ or services/ money paths except the two help wanted issues, which are deliberately issues, not part of this workstream's diffs — the money-code invariants and the drills are untouched by everything above.
Critical Files for Implementation¶
src/crypto_processing_api/main.py— app factory: OpenAPI metadata, tags, the surface every artifact derives fromsrc/crypto_processing_api/api/deposits.py— the pattern file for response-model + operation_id retrofit (its serializers/response_model=Nonestyle repeats across all routers)src/crypto_processing_api/core/signing.py— reference implementation both SDK webhook helpers port, and the source of the shared test vectors.github/workflows/release.yml— gains the PyPI trusted-publishing and npm-provenance jobs; already holdsid-token: writedocs/integrating.md— the backbone of the docs site's integrator section and the contract (idempotency, webhook 5-step) the SDK ergonomics encode