Lease endpoints
Walkthrough of the trader lease lifecycle: booking, Solana Pay payment, credentials, signing tokens, extensions, ratings, and the payment ledger.
This page documents every lease endpoint with exact request and response
shapes, in the order a trader actually uses them: book, pay, read,
reveal credentials, mint signing JWTs, then extend, rate, and audit.
All shapes come from the generated OpenAPI contract, served live at
GET https://api.swqos.dev/v1/openapi.json.
Endpoint summary
| Method | Path | Auth | Idempotency-Key |
|---|---|---|---|
POST | /v1/leases | bearer | required |
GET | /v1/leases | bearer | |
GET | /v1/leases/{id} | bearer | |
POST | /v1/leases/{id}/extend | bearer | required |
PATCH | /v1/leases/{id}/auto-renew | bearer | |
POST | /v1/leases/{id}/credentials | bearer | |
POST | /v1/leases/{id}/api-keys | bearer | |
POST | /v1/leases/{id}/token | bearer | |
POST | /v1/leases/{id}/rating | bearer | |
GET | /v1/payments | bearer | |
GET | /v1/solana-pay/{memo} | public | |
POST | /v1/solana-pay/{memo} | public |
"Bearer" means Authorization: Bearer <token> with either a Clerk
session JWT (web) or a lease API token. The Solana Pay pair is
intentionally unauthenticated: the unguessable ULID memo is the
capability, and wallets must be able to call it. See
Authentication for token sources
and rate limits.
Every authenticated success response is wrapped in the standard
envelope {"data": ..., "meta": {"request_id": "<ULID>", "version": "1.0"}}.
The Solana Pay pair returns raw bodies (the Solana Pay
transaction-request spec defines their shape). Errors are RFC 7807
problem documents served as application/problem+json, catalogued on
the Errors page.
Bootstrap: a trader API token only exists after the first lease's one-shot credentials reveal. The first booking therefore happens signed in on the web app (or with a Clerk session JWT). Every call after that can use the lease API token.
Book a lease
POST /v1/leases creates a pending_payment lease for the
authenticated trader. The Idempotency-Key header is required: a
26-character Crockford-base32 ULID. Replays within 24 hours return the
cached response, a missing key is 400 idempotency-key-missing, a
non-ULID key is 400 idempotency-key-malformed, and the same key with
a different body is 422 idempotency-key-reused.
curl -X POST https://api.swqos.dev/v1/leases \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: 01JXF69ZD0Q1W2E3R4T5Y6A7B8" \
-H "Content-Type: application/json" \
-d '{
"validator_id": "01JXEZV2P9R8S7T6W5X4Y3Z2A1",
"epoch_start": 982,
"epoch_count": 5,
"mode": "exclusive"
}'
| Field | Type | Required | Description |
|---|---|---|---|
validator_id | string | yes | ULID public_id of the target validator (must be live) |
epoch_start | integer | yes | First Solana epoch covered, inclusive |
epoch_count | integer | yes | Number of epochs, 1 to 30 |
mode | string | no | exclusive (default) or shared |
exclusive locks the epoch range for this trader alone. shared
co-occupies it with up to the validator's seat cap, at the validator's
separate shared price. Booking shared against a validator that does
not offer it returns 422 shared-not-offered.
A successful booking returns 201 with the payment instructions:
{
"data": {
"lease_id": "01JXF6A9QK3ZT8MEWB2C4D5G7H",
"payment": {
"solana_pay_url": "solana:https%3A%2F%2Fapi.swqos.dev%2Fv1%2Fsolana-pay%2F01JXF6AB0RCD3EFG4HJK5MN6P7",
"amount_lamports": 10000000000,
"memo": "01JXF6AB0RCD3EFG4HJK5MN6P7"
}
},
"meta": {
"request_id": "01JXF6ACT9K8J7H6G5F4D3S2A1",
"version": "1.0"
}
}
| Field | Description |
|---|---|
lease_id | ULID of the new lease, used in every later call |
payment.solana_pay_url | Solana Pay transaction-request URL (solana: plus a percent-encoded link). Render it as a QR code or wallet deep link |
payment.amount_lamports | Exact amount owed in lamports (validator share plus platform fee). The settlement worker rejects anything that credits less |
payment.memo | SPL memo the payment transaction carries verbatim, the only key correlating the on-chain transfer back to the lease |
The lease starts in pending_payment and holds the slot (or shared
seat) for 15 minutes. If the payment never confirms in that window
the lease flips to expired_unpaid and the slot frees.
Booking errors:
| Status | Slug | Meaning |
|---|---|---|
| 400 | invalid-epoch-start, invalid-epoch-range, lease-total-overflow | Bad epoch inputs |
| 403 | cannot-lease-own-validator | You cannot lease a validator you registered |
| 404 | validator-not-found | No live validator with that validator_id |
| 409 | lease-slot-taken | Exclusive or cross-mode overlap. extensions.conflicting_lease_id names the blocking lease when resolvable |
| 409 | shared-capacity-full | Every shared seat is taken somewhere in the range |
| 422 | shared-not-offered | Validator does not offer shared mode |
| 429 | rate-limit-exceeded | Booking is limited to 10/min per trader |
A 409 lease-slot-taken problem document looks like this:
{
"type": "https://docs.swqos.dev/errors/lease-slot-taken",
"title": "Lease slot already booked",
"status": 409,
"detail": "Requested epoch range overlaps an existing lease for this validator.",
"extensions": {
"conflicting_lease_id": "01JXEZWXY2Z3A4B5C6D7E8F9G0"
}
}
Pay: the Solana Pay pair
Payment is non-custodial. The trader signs one transaction that pays
the validator and the platform atomically (a 90/10 split, two
SystemProgram::transfer legs plus the SPL memo). The marketplace
never holds funds. Both endpoints are public and not enveloped.
GET /v1/solana-pay/
Wallets call this first to render the payment metadata:
curl https://api.swqos.dev/v1/solana-pay/01JXF6AB0RCD3EFG4HJK5MN6P7
{
"label": "swqos-lease",
"icon": "https://app.swqos.dev/favicon.ico"
}
POST /v1/solana-pay/
The wallet then posts its account to receive the unsigned transaction:
curl -X POST https://api.swqos.dev/v1/solana-pay/01JXF6AB0RCD3EFG4HJK5MN6P7 \
-H "Content-Type: application/json" \
-d '{"account": "7nYabs9dUhvxYwdTnrWVBL9MYviKSfrEbdWmUWHa8oQP"}'
{
"transaction": "AQAAAAAAAAAAAAAAAAAAAAAA...",
"message": "swqos lease payment (10000000000 lamports)"
}
transaction is a base64 unsigned legacy transaction with the
validator transfer, the platform transfer, and the memo. The wallet
signs and submits it.
| Status | Slug | Meaning |
|---|---|---|
| 404 | solana-pay-memo-not-found | Memo is unknown, expired, or already paid. This kills the late-payment race at build time |
| 409 | payer-is-payout-wallet | The paying account is the validator payout wallet, so the validator leg could never be verified. Pay from a different wallet |
| 422 | invalid-account | account is not a base58 32-byte pubkey |
| 503 | solana-rpc-unavailable | Blockhash fetch failed, retry shortly |
There is no payment-confirmation endpoint. The settlement worker
(Geyser stream plus a catch-up poller) watches the chain, verifies
both legs of the finalized transaction, and flips the lease to
active. Poll GET /v1/leases/{id} until status changes.
Read leases
GET /v1/leases
Lists the authenticated trader's leases, newest first. Other traders' leases are filtered out at the database.
GET /v1/leases/
Single lease lookup. Returns 404 lease-not-found both when the lease
does not exist and when it belongs to another trader (existence is
deliberately not disclosed).
Both return LeaseView bodies:
{
"data": {
"lease_id": "01JXF6A9QK3ZT8MEWB2C4D5G7H",
"validator_id": "01JXEZV2P9R8S7T6W5X4Y3Z2A1",
"mode": "exclusive",
"epoch_start": 982,
"epoch_end": 986,
"price_per_epoch_lamports": 2000000000,
"total_amount_lamports": 10000000000,
"status": "active",
"credentials_revealed": true,
"auto_renew": false,
"signs_count": 18243,
"booked_epoch_count": 5,
"payment": null,
"pending_extension_epochs": null,
"pending_extension_payment": null,
"refund_amount_lamports": null,
"revoked_reason": null
},
"meta": { "request_id": "01JXF6ACT9K8J7H6G5F4D3S2A1", "version": "1.0" }
}
Status values:
status | Meaning |
|---|---|
pending_payment | Booked, slot held, awaiting on-chain confirmation (15-minute window) |
active | Payment confirmed, credentials and JWTs available |
completed | Last paid epoch has elapsed |
revoked | Revoked by the validator or platform. revoked_reason and refund_amount_lamports are populated |
expired_unpaid | Payment never confirmed in the window, slot released |
Field notes:
| Field | Description |
|---|---|
payment | Solana Pay instructions, present only while pending_payment so an unpaid booking can be resumed, null once paid or expired |
pending_extension_epochs / pending_extension_payment | Set while an extension is awaiting payment |
price_per_epoch_lamports | Price frozen at booking (the exclusive or shared price per mode). Later validator price changes never touch it |
booked_epoch_count | The original epoch_count, never mutated by extensions |
signs_count | Lifetime real TEE signing calls served for this lease, the volume that gates rating eligibility |
credentials_revealed | true once the one-shot credentials reveal has been consumed |
Reveal credentials (one-shot)
POST /v1/leases/{id}/credentials returns the connection credentials
exactly once per lease. The cleartext api_token is only ever
returned in this response, the server keeps an Argon2id hash.
curl -X POST https://api.swqos.dev/v1/leases/01JXF6A9QK3ZT8MEWB2C4D5G7H/credentials \
-H "Authorization: Bearer $TOKEN"
{
"data": {
"tee_endpoint_url": "https://tee.validator-example.com:443",
"api_token": "01JXF6AEYWZ0X1V2T3S4R5Q6P7",
"tls_ca_cert": "-----BEGIN CERTIFICATE-----\n..."
},
"meta": { "request_id": "01JXF6ACT9K8J7H6G5F4D3S2A1", "version": "1.0" }
}
| Field | Description |
|---|---|
tee_endpoint_url | gRPC endpoint of the TEE host-proxy for this lease's validator |
api_token | Bearer token for all later API calls on this lease. Store it now, it cannot be re-read |
tls_ca_cert | PEM CA bundle the SDK pins for the TLS handshake to the host-proxy |
| Status | Slug | Meaning |
|---|---|---|
| 404 | credentials-already-shown | The reveal was already consumed once. Use rotation to recover |
| 404 | lease-not-found | No such lease owned by the caller |
| 422 | lease-not-active | Lease is not active yet (for example still pending_payment) |
Rotate the API token
POST /v1/leases/{id}/api-keys revokes the current trader token (if
any) and mints a fresh one. It works on any active lease whether or
not the one-shot reveal happened, so it is the recovery path for a
lost token. The response shape is identical to the credentials reveal.
curl -X POST https://api.swqos.dev/v1/leases/01JXF6A9QK3ZT8MEWB2C4D5G7H/api-keys \
-H "Authorization: Bearer $TOKEN"
| Status | Slug | Meaning |
|---|---|---|
| 404 | lease-not-found | No such lease owned by the caller |
| 409 | concurrent-rotation | Another rotation landed first, retry to pick up the latest token |
| 422 | lease-not-active | Lease is not active |
Mint signing JWTs
POST /v1/leases/{id}/token is the JWT issuer. Each call mints a
fresh ES256 JWT (signed via AWS KMS, kid in the header for
rotation). The TEE host-proxy verifies this JWT before serving signing
requests. Default TTL is 600 seconds with a hard cap of 3600.
curl -X POST https://api.swqos.dev/v1/leases/01JXF6A9QK3ZT8MEWB2C4D5G7H/token \
-H "Authorization: Bearer $TOKEN"
{
"data": {
"jwt": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Ii4uLiJ9...",
"expires_at": 1781136600
},
"meta": { "request_id": "01JXF6ACT9K8J7H6G5F4D3S2A1", "version": "1.0" }
}
expires_at is the exp claim copied out (seconds since the UNIX
epoch) so clients can schedule the refresh. Refresh about 60 seconds
before expiry. The TypeScript and
Python SDKs ship an opt-in background refresher
with that 60-second skew: start it with startAutoRefresh() /
start_auto_refresh(). The Rust SDK takes a static
JWT at connect time and never refreshes it, so Rust clients must mint
a fresh token and reconnect before expiry themselves.
Claims carried by the JWT:
| Claim | Meaning |
|---|---|
iss | Marketplace issuer URL |
sub | Trader ULID |
aud | The TEE endpoint URL, prevents cross-TEE replay |
val_pk | Validator identity pubkey (base58) |
lease_id | Lease public id |
epoch_range | [epoch_start, epoch_end] inclusive, informational |
exp / iat / nbf | Standard timestamps, nbf equals iat |
jti | Unique ULID per token |
quota | Optional { "max_handshakes_per_min": <n> }. Default budget 100/min. Shared leases get the budget split across seats: max(1, floor(budget / seats)) |
| Status | Slug | Meaning |
|---|---|---|
| 403 | lease-not-active | Lease is not active |
| 403 | lease-epoch-out-of-range | Current Solana epoch is outside [epoch_start, epoch_end] |
| 404 | lease-not-found | No such lease owned by the caller |
| 429 | rate-limit-exceeded | Token minting is limited to 12/min per identity |
Extend a lease
POST /v1/leases/{id}/extend bumps epoch_end forward in place:
same lease_id, same credentials, same frozen per-epoch price. The
Idempotency-Key header is required. The post-extension total span is
capped at 30 epochs.
curl -X POST https://api.swqos.dev/v1/leases/01JXF6A9QK3ZT8MEWB2C4D5G7H/extend \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: 01JXG2BA7CDE6FGH5JKM4NPQ3R" \
-H "Content-Type: application/json" \
-d '{"epoch_count": 3}'
{
"data": {
"lease_id": "01JXF6A9QK3ZT8MEWB2C4D5G7H",
"payment": {
"solana_pay_url": "solana:https%3A%2F%2Fapi.swqos.dev%2Fv1%2Fsolana-pay%2F01JXG2BC8DEF9GHJ0KMN1PQR2S",
"amount_lamports": 6000000000,
"memo": "01JXG2BC8DEF9GHJ0KMN1PQR2S"
},
"epoch_end": 989
},
"meta": { "request_id": "01JXF6ACT9K8J7H6G5F4D3S2A1", "version": "1.0" }
}
The extension mints a fresh memo and goes through the same Solana Pay
flow as the booking. Until it is paid, the lease shows
pending_extension_epochs and pending_extension_payment, and the
JWTs keep working for the already-paid range. An extension whose memo
is never paid is rolled back automatically.
| Status | Slug | Meaning |
|---|---|---|
| 404 | lease-not-found | No such lease owned by the caller |
| 409 | extension-already-pending | A previous extension is still awaiting payment |
| 409 | extension-race | A concurrent extension won, retry |
| 409 | lease-slot-taken | The new range overlaps another lease |
| 422 | lease-not-active | Lease is not active |
| 422 | lease-expired | Lease already ended |
| 422 | extension-exceeds-cap | Total span would exceed 30 epochs |
Toggle auto-renew
PATCH /v1/leases/{id}/auto-renew flips the auto_renew flag. Today
the flag only records the preference and is echoed back on the lease
view. No automated renewal exists yet, so the lease still completes at
its last paid epoch whatever the toggle says. To keep a lease running,
call POST /v1/leases/{id}/extend before the last epoch elapses.
Payment stays non-custodial in any case: the marketplace never
auto-charges a wallet.
curl -X PATCH https://api.swqos.dev/v1/leases/01JXF6A9QK3ZT8MEWB2C4D5G7H/auto-renew \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"enabled": true}'
{
"data": {
"lease_id": "01JXF6A9QK3ZT8MEWB2C4D5G7H",
"auto_renew": true
},
"meta": { "request_id": "01JXF6ACT9K8J7H6G5F4D3S2A1", "version": "1.0" }
}
Returns 404 lease-not-found when the lease does not exist or belongs
to another trader.
Rate a completed lease
POST /v1/leases/{id}/rating records a 1 to 5 star rating with an
optional comment (at most 500 characters). One rating per lease.
Eligibility is gated: the lease must be completed and must have served
real signing volume (the signs_count on the lease view, threshold
10,000 signatures by default).
curl -X POST https://api.swqos.dev/v1/leases/01JXF6A9QK3ZT8MEWB2C4D5G7H/rating \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"stars": 5, "comment": "Solid uptime, zero evictions all week."}'
{
"data": {
"lease_id": "01JXF6A9QK3ZT8MEWB2C4D5G7H",
"rating_id": "01JXH8CD7EFG6HJK5MNP4QRS3T",
"stars": 5
},
"meta": { "request_id": "01JXF6ACT9K8J7H6G5F4D3S2A1", "version": "1.0" }
}
| Status | Slug | Meaning |
|---|---|---|
| 400 | invalid-stars | stars outside 1 to 5 |
| 400 | invalid-comment | Comment longer than 500 characters |
| 404 | lease-not-found | No such lease owned by the caller |
| 409 | already-rated | This lease already has a rating |
| 422 | rating-not-yet-eligible | Lease not completed yet, or not enough signing volume |
Payment ledger
GET /v1/payments returns the trader's settled-payment ledger, newest
first. Unlike GET /v1/leases, which collapses extensions into one
cumulative range, each row here is a single confirmed on-chain
settlement (the initial booking plus every extension) with the exact
epoch span it paid for.
curl https://api.swqos.dev/v1/payments \
-H "Authorization: Bearer $TOKEN"
{
"data": [
{
"lease_id": "01JXF6A9QK3ZT8MEWB2C4D5G7H",
"validator_id": "01JXEZV2P9R8S7T6W5X4Y3Z2A1",
"epoch_start": 987,
"epoch_end": 989,
"amount_lamports": 6000000000,
"kind": "extension",
"confirmed_at": "2026-06-09T14:21:07Z",
"onchain_tx_signature": "5VERYLongBase58SignatureExample..."
},
{
"lease_id": "01JXF6A9QK3ZT8MEWB2C4D5G7H",
"validator_id": "01JXEZV2P9R8S7T6W5X4Y3Z2A1",
"epoch_start": 982,
"epoch_end": 986,
"amount_lamports": 10000000000,
"kind": "booking",
"confirmed_at": "2026-06-01T09:03:44Z",
"onchain_tx_signature": "2AnotherBase58SignatureExample..."
}
],
"meta": { "request_id": "01JXF6ACT9K8J7H6G5F4D3S2A1", "version": "1.0" }
}
kind is "booking" for a lease's first payment and "extension"
for each later in-place extend. onchain_tx_signature links to an
explorer.
Local development
All examples work against a local stack at http://localhost:8080
(see the backend docker-compose setup). Substitute the base URL, the
endpoints and shapes are identical.
Next steps
Token sources, the idempotency contract, and per-route rate limits.
The full problem-type catalogue with recommended client behaviour.
The same lifecycle walked through from the trader dashboard.
From credentials to your first confirmed transaction through the TEE.