openapi: 3.1.0 info: title: Payzuno API version: "1.0.0" description: | Payzuno — minimized-custody crypto payment rails. Create invoices (deposits), pay out (withdrawals), refund, and reconcile via signed webhooks. **Auth:** every request carries `Authorization: Bearer `. Live keys additionally sign each request (HMAC); the SDK does this automatically. **Idempotency:** POSTs that create resources require an `Idempotency-Key` header; retries return the original result. **Money:** amounts are strings in base units where noted (no floats). license: name: Proprietary — © Payzuno. All rights reserved. url: https://payzuno.io servers: - url: https://api.payzuno.io description: Production - url: http://127.0.0.1:8080 description: Local / sandbox security: - bearerAuth: [] tags: - name: Invoices description: "Deposits: create invoices/orders, mint pay addresses, hosted checkout and status." - name: Payouts description: "Withdrawals: single and batch transfers to external addresses, with policy + KYT." - name: Refunds description: "Pull-payment refunds: grant a one-time claim, then the customer claims to their address." - name: Account description: "Merchant balances and the assets this deployment supports." - name: Players description: "Responsible-gambling controls for iGaming: block or allow a player." - name: On-ramp description: "Fiat-to-crypto on-ramp orders." paths: /v1/invoices: post: operationId: postInvoices tags: [Invoices] summary: Create an invoice (deposit request) parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: type: object required: [price_usd, asset_id] properties: price_usd: { type: string, example: "25.00" } asset_id: { type: string, example: "USDC", description: "ETH, USDC, USDT, BTC, USDT-TRC20" } expires_in: { type: integer, description: seconds until expiry } player_id: { type: string, description: "iGaming: responsible-gambling gate" } order_id: type: string maxLength: 512 example: ORDER-4471 description: | Your own order reference. Echoed back on the invoice, on the public status endpoint, and on every webhook for it — this is what you reconcile on, since `id` is ours, not yours. order_description: type: string maxLength: 512 example: Two tickets, aisle seats description: Shown to the payer on the hosted checkout. success_url: type: string format: uri maxLength: 2048 example: https://shop.example.com/thanks description: | Where the hosted checkout sends the customer once the invoice is credited. A button appears immediately and an automatic redirect follows a few seconds later, so the customer sees the confirmation before leaving. Must be absolute and **https** (plain `http` is accepted only for `localhost`/`127.0.0.1`, for local development), and may not contain embedded credentials. This page is served from Payzuno's origin, so a redirect target inherits our credibility with a customer who is thinking about money — the rules are tight on purpose. cancel_url: type: string format: uri maxLength: 2048 description: | Offered as "Cancel and return" while the invoice is still awaiting payment. Same URL rules as `success_url`. partially_paid_url: type: string format: uri maxLength: 2048 description: | Where the customer is offered a way back when the invoice ends up `underpaid` or `late_held` — paid, but not credited, with the funds held. Same URL rules as `success_url`. responses: "201": description: Invoice created content: application/json: schema: { $ref: '#/components/schemas/Invoice' } "200": description: | Idempotent replay — this `Idempotency-Key` was already used by this merchant, so the ORIGINAL invoice is returned unchanged. Note the status is 200, not 201: treat both as success. A retry must never mint a second deposit address, because the customer may already be paying into the first. headers: Idempotent-Replayed: schema: { type: string, enum: ["true"] } content: application/json: schema: { $ref: '#/components/schemas/Invoice' } "400": description: | `idempotency_key_required` (the header is missing or empty), `parameter_missing` (`price_usd` or `asset_id`), or `invalid_parameter` (unknown asset, unparseable amount, below the per-asset minimum, an over-long `order_id`/`order_description`, or a redirect URL that is relative, not https, or carries credentials — the offending field is named in `error.param`). content: application/json: schema: { $ref: '#/components/schemas/Error' } "401": { $ref: '#/components/responses/Error' } "403": { $ref: '#/components/responses/Error' } get: operationId: getInvoices tags: [Invoices] summary: List invoices parameters: - { name: limit, in: query, schema: { type: integer, default: 25, maximum: 100 } } responses: "4XX": { $ref: '#/components/responses/Error' } "200": description: OK content: application/json: schema: type: object properties: data: { type: array, items: { $ref: '#/components/schemas/Invoice' } } has_more: { type: boolean } /v1/estimate: get: operationId: getEstimate tags: [Invoices] summary: Estimate the crypto amount for a fiat price description: > Quotes the amount of an asset owed for a USD price without creating an invoice or deriving an address. Uses the same round-up rule as invoice creation, so the figure matches what a real invoice would expect. parameters: - { name: amount, in: query, required: true, schema: { type: string }, description: "Fiat amount, e.g. \"100.00\"" } - { name: asset, in: query, required: true, schema: { type: string }, description: "Asset id, e.g. USDC" } responses: "200": description: OK content: application/json: schema: type: object properties: object: { type: string, example: estimate } pricing: type: object properties: amount: { type: string, description: USD in minor units } currency: { type: string, example: USD } display: { type: string, example: "100.00" } asset: { type: string } estimated_amount: { $ref: '#/components/schemas/Money' } quote: type: object properties: id: { type: string } rate: { type: string, description: USD per whole asset unit } valid_until: { type: string, format: date-time } "400": { $ref: '#/components/responses/Error' } /v1/min-amount: get: operationId: getMinAmount tags: [Invoices] summary: Minimum invoice amount for an asset description: > The smallest invoice value for an asset, below which network fees make a deposit uneconomic to sweep. Returned in both USD and the asset. parameters: - { name: asset, in: query, required: true, schema: { type: string }, description: "Asset id, e.g. BTC" } responses: "200": description: OK content: application/json: schema: type: object properties: object: { type: string, example: min_amount } asset: { type: string } min_usd: { type: string, example: "1.00" } min_amount: { $ref: '#/components/schemas/Money' } "400": { $ref: '#/components/responses/Error' } /v1/invoices/{id}: get: operationId: getInvoicesById tags: [Invoices] summary: Retrieve an invoice parameters: [ { name: id, in: path, required: true, schema: { type: string } } ] responses: "200": description: OK content: { application/json: { schema: { $ref: '#/components/schemas/Invoice' } } } "404": { $ref: '#/components/responses/Error' } /v1/payouts: post: operationId: postPayouts tags: [Payouts] summary: Withdraw funds to an address parameters: [ $ref: '#/components/parameters/IdempotencyKey' ] requestBody: required: true content: application/json: schema: type: object required: [amount, asset_id, destination] properties: amount: { type: string, example: "20" } asset_id: { type: string, example: "USDT" } destination: { type: string, example: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" } responses: "4XX": { $ref: '#/components/responses/Error' } "200": description: Payout accepted (may be pending/confirming) content: { application/json: { schema: { $ref: '#/components/schemas/Payout' } } } get: operationId: getPayouts tags: [Payouts] summary: List payouts / transfers responses: "4XX": { $ref: '#/components/responses/Error' } "200": description: OK content: application/json: schema: { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/Payout' } } } } /v1/payouts/batch: post: operationId: postPayoutsBatch tags: [Payouts] summary: Batch payouts (≤100, sequential, per-item isolation) requestBody: required: true content: application/json: schema: type: object properties: items: type: array maxItems: 100 items: type: object required: [amount, destination] properties: amount: { type: string } asset_id: { type: string } destination: { type: string } responses: "4XX": { $ref: '#/components/responses/Error' } "200": description: Per-item results content: application/json: schema: type: object properties: total: { type: integer } succeeded: { type: integer } data: { type: array, items: { type: object } } /v1/invoices/{id}/refunds: post: operationId: postInvoicesByIdRefunds tags: [Refunds] summary: Grant a refund → one-time claim link (shown once) parameters: [ { name: id, in: path, required: true, schema: { type: string } } ] requestBody: content: application/json: schema: { type: object, properties: { amount: { type: string, description: "omit for full refundable" } } } responses: "4XX": { $ref: '#/components/responses/Error' } "201": description: Refund granted content: application/json: schema: type: object properties: id: { type: string } claim_url: { type: string, description: "send this to the customer; shown once" } /v1/refunds: get: operationId: getRefunds tags: [Refunds] summary: List refunds responses: { "4XX": { $ref: '#/components/responses/Error' }, "200": { description: OK } } /v1/refunds/{id}/cancel: post: operationId: postRefundsByIdCancel tags: [Refunds] summary: Cancel an unclaimed refund parameters: [ { name: id, in: path, required: true, schema: { type: string } } ] responses: { "4XX": { $ref: '#/components/responses/Error' }, "200": { description: Cancelled } } /v1/balances: get: operationId: getBalances tags: [Account] summary: Merchant balances per asset responses: "4XX": { $ref: '#/components/responses/Error' } "200": description: OK content: application/json: schema: type: object properties: tenant: { type: string } balances: type: object additionalProperties: { $ref: '#/components/schemas/Money' } /v1/assets: get: operationId: getAssets tags: [Account] summary: Assets this deployment supports responses: "4XX": { $ref: '#/components/responses/Error' } "200": description: OK content: application/json: schema: type: object properties: data: type: array items: { type: object, properties: { id: { type: string }, decimals: { type: integer } } } /v1/players/{id}: get: operationId: getPlayersById tags: [Players] summary: Get a player's status (responsible gambling) parameters: [ { name: id, in: path, required: true, schema: { type: string } } ] responses: { "4XX": { $ref: '#/components/responses/Error' }, "200": { description: OK } } put: operationId: putPlayersById tags: [Players] summary: Block / allow a player parameters: [ { name: id, in: path, required: true, schema: { type: string } } ] requestBody: content: application/json: schema: type: object properties: status: { type: string, enum: [allowed, blocked] } reason: { type: string } responses: { "4XX": { $ref: '#/components/responses/Error' }, "200": { description: OK } } /v1/onramp/orders: post: operationId: postOnrampOrders tags: [On-ramp] summary: Create a fiat→crypto on-ramp order (UPI, via aggregator) requestBody: content: application/json: schema: type: object properties: amount_inr: { type: string } asset_id: { type: string } player_id: { type: string } responses: "4XX": { $ref: '#/components/responses/Error' } "201": { description: Hosted UPI checkout URL returned } "503": { description: on-ramp not configured } get: operationId: getOnrampOrders tags: [On-ramp] summary: List on-ramp orders responses: { "4XX": { $ref: '#/components/responses/Error' }, "200": { description: OK } } /v1/invoice: post: operationId: postInvoice tags: [Orders] summary: Create an order (no currency, no address yet) description: | Records what you are charging **before** anyone has decided how to pay it. No deposit address is derived and no chain watch registered — an order is not yet a payment. Send the customer to `invoice_url`; the hosted checkout shows a currency picker and mints the address once they choose. Pass `pay_currency` here instead if you already know it, and the first payment is minted with the order. **`/v1/invoice` (singular) is not `/v1/invoices` (plural).** One letter apart and genuinely different: the singular form creates an ORDER, the plural one creates a payment with the currency already fixed. Both are supported and neither is going away — the singular form matches the vocabulary merchants arrive with from other providers. Unlike those providers, an `Idempotency-Key` header is required. A retry without one that minted a second order would give the customer two addresses for one basket. parameters: - { name: Idempotency-Key, in: header, required: true, schema: { type: string } } requestBody: required: true content: application/json: schema: type: object required: [price_amount] properties: price_amount: { type: string, example: "25.00" } price_currency: type: string default: USD description: | Must be USD. The pricing feed quotes USD→asset and nothing else, so another currency is refused rather than charged as dollars at a rate we do not have. price_usd: { type: string, description: "alias for price_amount" } pay_currency: type: string description: mint the first payment immediately instead of asking the customer expires_in: { type: integer, description: "seconds; default 86400, far longer than an invoice" } order_id: { type: string, maxLength: 512 } order_description: { type: string, maxLength: 512 } success_url: { type: string, format: uri } cancel_url: { type: string, format: uri } partially_paid_url: { type: string, format: uri } responses: "201": description: Order created content: { application/json: { schema: { $ref: '#/components/schemas/Order' } } } "200": description: Idempotent replay — the original order, unchanged. headers: Idempotent-Replayed: { schema: { type: string, enum: ["true"] } } content: { application/json: { schema: { $ref: '#/components/schemas/Order' } } } "400": { $ref: '#/components/responses/Error' } "401": { $ref: '#/components/responses/Error' } get: operationId: getInvoice tags: [Orders] summary: List your orders responses: "4XX": { $ref: '#/components/responses/Error' } "200": description: OK content: application/json: schema: type: object properties: data: { type: array, items: { $ref: '#/components/schemas/Order' } } /v1/invoice/{id}: get: operationId: getInvoiceById tags: [Orders] summary: Retrieve an order and its payment attempts parameters: [ { name: id, in: path, required: true, schema: { type: string } } ] responses: "200": description: OK content: { application/json: { schema: { $ref: '#/components/schemas/Order' } } } "404": { $ref: '#/components/responses/Error' } /v1/invoice/{id}/cancel: post: operationId: postInvoiceByIdCancel tags: [Orders] summary: Cancel an unpaid order description: | Refused once anything has been credited against the order. At that point the merchant has the money and the thing to do is refund, not pretend the order never existed. parameters: [ { name: id, in: path, required: true, schema: { type: string } } ] responses: "200": description: Cancelled content: { application/json: { schema: { $ref: '#/components/schemas/Order' } } } "400": { $ref: '#/components/responses/Error' } "404": { $ref: '#/components/responses/Error' } /v1/invoice-payment: post: operationId: postInvoicePayment tags: [Orders] summary: Choose a currency for an order and get the address description: | Mints the deposit address for one currency on an existing order. No `Idempotency-Key`, unlike the routes that create resources: this is already idempotent by construction — the same order and the same currency return the payment already minted rather than deriving a second address for it. A customer may change their mind; each new currency mints its own payment, and abandoned addresses stay watched. A payment sent to one still credits, because invalidating it would turn a customer's honest mistake into lost funds. Capped at 10 attempts per order. requestBody: required: true content: application/json: schema: type: object required: [iid, pay_currency] properties: iid: { type: string, description: the order id, example: ord_3 } pay_currency: { type: string, example: ETH } responses: "201": description: Payment created content: { application/json: { schema: { $ref: '#/components/schemas/Payment' } } } "400": description: Unknown currency, expired order, or the attempt cap was reached. content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } "404": { $ref: '#/components/responses/Error' } /v1/payment: post: operationId: postPayment tags: [Orders] summary: Create an order and its payment in one call description: | For a merchant who already knows the currency. Equivalent to `POST /v1/invoice` with `pay_currency` set, but returns the PAYMENT rather than the order, because that is what a caller reaching for this endpoint wants. parameters: - { name: Idempotency-Key, in: header, required: true, schema: { type: string } } requestBody: required: true content: application/json: schema: type: object required: [price_amount, pay_currency] properties: price_amount: { type: string, example: "25.00" } price_currency: { type: string, default: USD } pay_currency: { type: string, example: ETH } order_id: { type: string } order_description: { type: string } success_url: { type: string, format: uri } cancel_url: { type: string, format: uri } partially_paid_url: { type: string, format: uri } responses: "201": description: Payment created content: { application/json: { schema: { $ref: '#/components/schemas/Payment' } } } "200": description: Idempotent replay — the original payment, unchanged. headers: Idempotent-Replayed: { schema: { type: string, enum: ["true"] } } content: { application/json: { schema: { $ref: '#/components/schemas/Payment' } } } "400": { $ref: '#/components/responses/Error' } /v1/payment/{id}: get: operationId: getPaymentById tags: [Orders] summary: Retrieve a payment parameters: [ { name: id, in: path, required: true, schema: { type: string } } ] responses: "200": description: OK content: { application/json: { schema: { $ref: '#/components/schemas/Payment' } } } "404": { $ref: '#/components/responses/Error' } /pay/{id}/select: post: operationId: postPayByIdSelect tags: [Orders] summary: The payer picks a currency (no auth) description: | What the hosted checkout's dropdown calls. Unauthenticated by necessity — the customer paying holds no API key and the merchant is not in the room — so what it can do is deliberately narrow: it needs an existing open order id, it can only choose an asset this deployment supports, and attempts per order are capped. It cannot change the price, the recipient, or where the money goes. Merchants integrating server-side should use `/v1/invoice-payment` instead; this exists for the browser. security: [] parameters: [ { name: id, in: path, required: true, schema: { type: string } } ] requestBody: required: true content: application/json: schema: type: object required: [asset_id] properties: { asset_id: { type: string, example: ETH } } responses: "201": description: Address minted content: application/json: schema: type: object properties: payment_id: { type: string, example: in_7 } asset: { type: string } address: { type: string } chain: { type: string } next: { type: string, description: "where to send the browser, e.g. /pay/in_7" } "400": { description: "Unsupported asset, expired order, or attempt cap reached." } "404": { description: No such order. } /pay/{id}: get: operationId: getPayById tags: [Invoices] summary: Hosted checkout page (no auth) — redirect, embed, or pop up description: | A self-contained HTML page showing the amount, the deposit address, a QR, a countdown, and a live status that it polls from `/pay/{id}/status`. It carries the merchant's branding. Send the customer here rather than building a pay screen: it is the same page the status endpoint feeds, so the two can never disagree about what was asked for. **Redirect and popup work for everyone.** Embedding in an `