TL;DR: OpenAPI 3.1 finally fixed JSON Schema parity, but the toolchain is still half-stuck on 3.0. This post documents the practical decisions — spec authoring, hosting, multi-language client generation, versioning — that shipped a working crypto swap API consumed by Python, TypeScript, Rust, and curl clients in production.
I shipped an OpenAPI 3.1 spec for a no-KYC crypto swap aggregator earlier this year. It powers Python, TypeScript, Rust, and curl clients used by partner sites and CLI tools. Along the way I learned which parts of the OpenAPI tooling story are great in 2026 and which parts are still rough.
This is the practical post I wish I'd had three months ago.
Why OpenAPI 3.1 in 2026 (and not 3.0 or Swagger 2)
Three reasons that matter in practice:
Full JSON Schema 2020-12 parity. OpenAPI 3.0's "subset of JSON Schema draft 5" was a perpetual papercut —
nullable: trueinstead oftype: ["string", "null"], noif/then/else, no proper$dynamicRef. 3.1 finally aligns. Means you can use the same schema for runtime validation, codegen, and docs.Better
webhooksand event modeling. If your API emits callbacks (we do, for transaction status updates), 3.1 has first-class webhook description. Previously you faked it withx-webhooksextensions.info.summaryandinfo.license.identifier. Small but real DX wins for tooling that surfaces API metadata.
The catch: the codegen ecosystem hasn't fully caught up. openapi-generator-cli works fine. Some IDE extensions still default to 3.0 validation. Plan for that.
The minimum viable spec
For our /api/v2 we ended up with this skeleton (annotated). The full version is on GitHub.
yaml
openapi: 3.1.0
info:
title: MoneroSwapper API
version: '2.0.0'
summary: No-KYC cryptocurrency swap aggregator
description: |
Public REST API for fetching rates, listing supported currencies,
and creating non-custodial swap transactions across 1700+ coins.
license:
name: CC0-1.0
identifier: CC0-1.0
contact:
email: contact@moneroswapper.io
servers:
- url: https://moneroswapper.io/api/v2
description: Production (clearnet)
- url: http://llh6wrygjmhqsho6wturufyfkmy5haej74jatknm4qvr7wb4v5bg6zad.onion/api/v2
description: Production (Tor onion v3)
security:
- BearerAuth: []
- {} # Some endpoints don't require auth
paths:
/currencies:
get:
summary: List supported cryptocurrencies
security: [] # Public, no auth
parameters:
- name: search
in: query
schema:
type: string
description: Filter by ticker or name
responses:
'200':
description: List of currencies + total count
content:
application/json:
schema:
$ref: '#/components/schemas/CurrenciesResponse'
/rate:
get:
summary: Get exchange rate estimate
security: []
parameters:
- {name: coinFrom, in: query, required: true, schema: {type: string}}
- {name: coinTo, in: query, required: true, schema: {type: string}}
- {name: amount, in: query, required: true, schema: {type: number}}
responses:
'200':
description: Rate estimate with min/max bounds
content:
application/json:
schema:
$ref: '#/components/schemas/Rate'
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: API key
schemas:
Currency:
type: object
required: [code, name]
properties:
code: {type: string, examples: ["XMR"]}
name: {type: string, examples: ["Monero"]}
icon: {type: string, format: uri}
notes: {type: ["string", "null"]} # 3.1 syntax, not 3.0's nullable
CurrenciesResponse:
type: object
required: [data, count]
properties:
data:
type: array
items: {$ref: '#/components/schemas/Currency'}
count: {type: integer, minimum: 0}
Rate:
type: object
required: [fromAmount, toAmount, rate, minAmount, maxAmount]
properties:
fromAmount: {type: number}
toAmount: {type: number}
rate: {type: number}
minAmount: {type: number}
maxAmount: {type: number}
message: {type: ["string", "null"]}
Three things I learned about authoring the spec:
Use type: ["string", "null"] instead of nullable: true. It's the 3.1-native way and works in all 2024+ generators.
examples (plural) replaces 3.0's example (singular). Both still work; new tools prefer the array form.
Don't fight your tooling on oneOf/discriminator for polymorphism if it's not critical. Many generators still emit ugly code for it.
Hosting the spec: GitHub raw + your own domain
The two viable patterns in 2026:
Pattern A — GitHub raw (zero infrastructure)
https://raw.githubusercontent.com/<org>/<repo>/main/openapi.yaml
Pros: free, CDN-cached, versioned via git tags. Cons: GitHub raw has a 5MB limit and isn't designed as a CDN.
Pattern B — Serve from your own API host
https://yourdomain.io/openapi.json
Pros: tied to API versioning (you can serve /api/v2/openapi.json), works behind your auth/CORS rules. Cons: slightly more nginx config.
We do both: the GitHub repo is the source of truth (under git tags), and /openapi.json is regenerated from it on deploy. Spec lovers import the GitHub URL into Swagger Editor; production tools hit our own domain.
The nginx serving block is trivial:
location = /openapi.json {
alias /var/www/api/openapi.json;
add_header Content-Type "application/json";
add_header Access-Control-Allow-Origin "*";
add_header Cache-Control "public, max-age=300";
}
Five-minute cache is the sweet spot — long enough to absorb crawler bursts, short enough that a hot-fix to the spec propagates fast.
Multi-language client generation
This is the part where most spec-publishing projects die quietly. You publish the spec, three devs hit the OpenAPI Generator CLI, get garbage code, and never come back.
The fix: hand-write at least one client SDK in your most important language, then use the spec to generate templates for the others.
Why hand-write the canonical client
Generated clients in 2026 are good enough for getting started. They're rarely good enough for production. The pain points I hit:
Generated retry/timeout code is generic and over-conservative
Error type hierarchies are flat (every HTTP failure throws ApiException)
Async support is bolted on, not native
The README is auto-generated and unreadable
So I hand-wrote the Python client (moneroswapper-python) and TypeScript client (moneroswapper-js) using urllib and fetch respectively — zero external runtime dependencies. The Python client is about 130 lines:
class MoneroSwapperClient:
def __init__(self, base_url=API_BASE, api_key=None, timeout=20):
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.timeout = timeout
def list_currencies(self, search=None):
return self._request("GET", "/currencies",
params={"search": search})
def get_rate(self, coin_from, coin_to, amount):
return self._request("GET", "/rate",
params={"coinFrom": coin_from,
"coinTo": coin_to,
"amount": amount})
def create_transaction(self, coin_from, coin_to, amount,
withdrawal_address, refund_address=None):
if not self.api_key:
raise MoneroSwapperError(401, "create_transaction needs api_key")
return self._request("POST", "/transactions", body={
"coinFrom": coin_from, "coinTo": coin_to,
"amount": amount, "withdrawalAddress": withdrawal_address,
"refundAddress": refund_address,
})
Tests against the live API (only public endpoints, no auth):
def test_get_rate_btc_to_xmr():
client = MoneroSwapperClient()
rate = client.get_rate(coin_from="btc", coin_to="xmr", amount=0.01)
assert rate["fromAmount"] == 0.01
assert rate["toAmount"] > 0
assert "minAmount" in rate
assert "maxAmount" in rate
A live-API test like this is gold for two reasons: it catches drift between your spec and your actual API, and it's documentation new contributors can read in 10 seconds.
Using OpenAPI Generator for "second-tier" languages
For Rust, Go, Java, C#, etc — languages where you want client coverage but won't hand-write them — openapi-generator-cli is fine if you keep expectations modest:
openapi-generator-cli generate \
-i https://moneroswapper.io/openapi.json \
-g rust \
-o ./generated/rust \
--additional-properties=packageName=moneroswapper,supportAsync=true
The output is usable. Not idiomatic. Not pretty. But it compiles and it makes the right HTTP calls. Ship it, link to it from your spec repo, and let the community open issues with idiomatic improvements.
I also ship a curl quick-reference for the dev who just wants to copy-paste:
# Public quote endpoint
curl -s "https://moneroswapper.io/api/v2/rate?coinFrom=btc&coinTo=xmr&amount=0.01" | jq .
# Authenticated transaction creation
curl -s -X POST "https://moneroswapper.io/api/v2/transactions" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"coinFrom": "btc",
"coinTo": "xmr",
"amount": 0.01,
"withdrawalAddress": "<your XMR address>"
}'
This bash block ends up being our most-copied snippet by far.
Versioning: semver applied to API URLs
Skip the religious debates and use this:
Change type API URL Spec version
New endpoint, optional field /api/v2/... 2.1.0
Backward-incompatible change /api/v3/... 3.0.0
Bugfix, doc-only update /api/v2/... 2.0.1
Mount older versions in parallel — /api/v2/ and /api/v3/ both live, with the v2 spec on a legacy branch in your spec repo. Sunset old versions slowly (12+ months) and announce in the spec's info.description.
Tag git releases. Use them as canonical URLs in your docs:
https://raw.githubusercontent.com/<org>/api-spec/v2.0.0/openapi.json
Devs who hate breaking changes will pin to a tagged URL.
Things I'd skip if starting again
Don't ship a Swagger UI yourself. Use redoc-cli or the hosted version. Less to maintain.
Don't generate clients on every CI run. Generate on release, commit them to versioned tags, and let users pin.
Don't use oneOf for polymorphic responses unless you've tested every generator you care about. Plain string discriminators are easier to consume.
Don't include the spec in your main app repo. Separate repo (yourorg/api-spec) keeps the spec lifecycle independent of app deploys and makes external contributions easier.
What worked, with numbers
Six months of running this:
~280 npm/pypi-style installs of the hand-written clients (mostly Python)
~4,500 raw fetches of the GitHub-hosted openapi.json
3 community-submitted issues that caught real spec drift
0 incidents where a client broke because we changed the underlying API (the live tests caught everything)
The 3-issue figure was the surprise. Three is small, but each represented a real bug we wouldn't have found internally — typos in examples blocks, a missing minimum: 0 on an integer, a wrong type for a nullable field. Publishing the spec is itself a free QA pass.
Closing
Three concrete takeaways:
Use OpenAPI 3.1 even if your tooling lags. The spec correctness alone is worth it.
Hand-write your most important client. Let the generator handle the long tail.
Publish the spec from a dedicated repo with git tags. Treat it like the public artifact it is.
If you're building or maintaining a public API in 2026 and you don't have a published spec — you're paying tax in support requests and integration friction. Two weeks of effort, paid back forever.
MoneroSwapper is the no-KYC crypto swap aggregator this post draws from. The full OpenAPI 3.1 spec lives at github.com/moneroswapper/api-spec, with hand-written Python and TypeScript clients at github.com/moneroswapper.
Top comments (0)