> For the complete documentation index, see [llms.txt](https://developers.unigox.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://developers.unigox.com/api-reference/off-ramp.md).

# Off Ramp

Off-ramp operations (crypto to fiat) — sell crypto and receive fiat

## Get off-ramp price estimate

> Get indicative off-ramp pricing (crypto to fiat) without creating a quote.\
> Pricing can be driven from either side: provide \`crypto\_amount\` to compute the fiat payout, or \`fiat\_amount\` to compute the crypto required (exactly one of the two).\
> This endpoint is lightweight and does not reserve liquidity.\
> \
> Use this when you need quick pricing discovery before calling \`/partner/offramp/quote\`.\
> Optional filters: \`payment\_method\_slug\`, \`payment\_network\_slug\`, \`provider\_scope\`, \`country\_code\`.\
> This endpoint is indicative and does not apply user/payment-details ownership checks; for executable pricing use quote (which still requires an API key).\
> \
> \*\*Authentication:\*\* None. This is a public endpoint — no API key required (same as \`/partner/liquidity\`).\
> \
> \*\*Note:\*\* EUR, AUD, and GBP off-ramp payouts require the user's physical\
> address. Ensure the user's KYC includes \`address\`, \`city\`, and \`postal\_code\` before initiating.\
> See \`PATCH /partner/users/{user\_uuid}/kyc\` to update.<br>

```json
{"openapi":"3.0.3","info":{"title":"Unigox API Gateway","version":"1.0.0"},"tags":[{"name":"Off-Ramp","description":"Off-ramp operations (crypto to fiat) — sell crypto and receive fiat"}],"servers":[{"url":"https://api-staging.unigox.com","description":"Sandbox server"},{"url":"https://api.unigox.com","description":"Production server"}],"security":[],"paths":{"/api/v1/partner/offramp/estimate":{"post":{"tags":["Off-Ramp"],"summary":"Get off-ramp price estimate","description":"Get indicative off-ramp pricing (crypto to fiat) without creating a quote.\nPricing can be driven from either side: provide `crypto_amount` to compute the fiat payout, or `fiat_amount` to compute the crypto required (exactly one of the two).\nThis endpoint is lightweight and does not reserve liquidity.\n\nUse this when you need quick pricing discovery before calling `/partner/offramp/quote`.\nOptional filters: `payment_method_slug`, `payment_network_slug`, `provider_scope`, `country_code`.\nThis endpoint is indicative and does not apply user/payment-details ownership checks; for executable pricing use quote (which still requires an API key).\n\n**Authentication:** None. This is a public endpoint — no API key required (same as `/partner/liquidity`).\n\n**Note:** EUR, AUD, and GBP off-ramp payouts require the user's physical\naddress. Ensure the user's KYC includes `address`, `city`, and `postal_code` before initiating.\nSee `PATCH /partner/users/{user_uuid}/kyc` to update.\n","operationId":"getOffRampEstimate","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerPriceEstimateRequest"}}}},"responses":{"200":{"description":"Estimate retrieved successfully","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/APIResponse"},{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PartnerPriceEstimateResponse"}}}]}}}},"400":{"description":"Bad request — invalid parameters, missing required fields, \nunsupported cryptocurrency, or invalid provider_scope.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerErrorResponse"}}}},"409":{"description":"No offers available for the requested pair/amount","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerErrorResponse"}}}},"500":{"description":"Internal Server Error"}}}}},"components":{"schemas":{"PartnerPriceEstimateRequest":{"type":"object","required":["crypto_currency","fiat_currency"],"oneOf":[{"required":["crypto_amount"]},{"required":["fiat_amount"]}],"properties":{"crypto_currency":{"type":"string","description":"Cryptocurrency code (e.g., USDT, USDC)"},"fiat_currency":{"type":"string","description":"Target fiat currency code (e.g., NGN, USD, EUR)"},"crypto_amount":{"type":"string","description":"Crypto amount to estimate from. Provide exactly one of crypto_amount or fiat_amount."},"fiat_amount":{"type":"string","description":"Fiat amount to estimate from. Provide exactly one of crypto_amount or fiat_amount."},"payment_method_slug":{"type":"string","description":"Optional payment method slug filter to narrow routing"},"payment_network_slug":{"type":"string","description":"Optional payment network slug filter to narrow routing"},"provider_scope":{"type":"string","description":"Optional provider scope filter; defaults to all","enum":["all","licensed_only","p2p_only"]},"country_code":{"type":"string","description":"Optional ISO country code (alpha-2) used for fee/routing context"},"partner_fee_pct":{"type":"string","pattern":"^[0-9]+(\\.[0-9]+)?$","description":"Optional per-order markup you charge your end user, applied on the same crypto base\nas the platform fee (non-compounding). Percent units: `1` means 1%. Omitting the field\nfrom this request payload — or sending `0` — means no markup; the markup cannot be\nnegative. Fiat-anchored offramp orders have no cap — the deposit grows to cover the\nmarkup. Every other case hits a formula domain bound, not a business cap: onramp\nrejects `partner_fee_pct >= 100` (the withheld or grossed-up fee would consume the\nentire buyer amount, leaving nothing delivered); crypto-anchored offramp rejects it\nwhen the fee would consume the entire pinned deposit.\n\nNote: you send this value as a JSON **string**, but it round-trips as a JSON **number**\nin `fee_breakdown.partner_fee_pct` and in the webhook `data.partner_fee_pct` field —\ndon't parse those as strings.\n\n**Rejection reference:**\n\n| Rejection | `error.code` | HTTP status |\n|---|---|---|\n| `partner_fee_pct` is non-numeric, negative, `NaN`, or `Inf` | `INVALID_REQUEST` | 400 |\n| Onramp: fee would consume the entire buyer amount (`partner_fee_pct >= 100`) | `INVALID_REQUEST` | 400 |\n| Crypto-anchored offramp: fee would consume the entire pinned deposit | `INVALID_REQUEST` | 400 |\n| Fiat-anchored offramp: the computed fee is not a finite amount | `INVALID_REQUEST` | 400 |\n| Crypto-anchored offramp: the carved fee-exclusive gross falls outside the matched offer's min/max | `NO_OFFERS_AVAILABLE` | 409 |\n","default":"0"}}},"APIResponse":{"type":"object","required":["success"],"properties":{"success":{"type":"boolean","description":"Indicates if the request was successful"},"data":{"description":"Response data (structure varies by endpoint) or error information if success is false"}}},"PartnerPriceEstimateResponse":{"type":"object","properties":{"rate":{"type":"string","description":"Indicative crypto-to-fiat exchange rate"},"crypto_amount":{"type":"string","description":"Estimated total crypto amount — the fee-exclusive gross: the user's amount plus the platform\nfee. Partner-fee-exclusive, except for a crypto-anchored onramp estimate (request declares\n`crypto_amount`): there the amount is sized up so the delivered amount stays exact after the\npartner fee is withheld, so this field includes the markup. A crypto-anchored offramp\nestimate carves both fees out of the pinned deposit instead, so this field and\n`fee_breakdown.platform_fee` both shrink relative to a no-markup request, even though the\nmarkup itself stays out of this field. Every other case is unaffected — the markup is\nreported separately in `fee_breakdown.partner_fee`.\n\nOn-ramp: the end user would receive `crypto_amount − fee_breakdown.platform_fee −\nfee_breakdown.partner_fee` for a fiat-anchored estimate, or exactly this `crypto_amount` for a\ncrypto-anchored estimate. Off-ramp: the amount you would actually fund at initiation is\n`crypto_amount + fee_breakdown.partner_fee`.\n"},"fiat_amount":{"type":"string","description":"Estimated fiat amount"},"fee_breakdown":{"$ref":"#/components/schemas/FeeBreakdown"},"payment_method_slug":{"type":"string","description":"Matched payment method slug used for estimate"},"payment_network_slug":{"type":"string","description":"Matched payment network slug used for estimate"},"provider_scope":{"type":"string","description":"Applied provider scope"},"is_indicative":{"type":"boolean","description":"True when response is an estimate and not a reserving quote"}}},"FeeBreakdown":{"type":"object","description":"Transparent breakdown of all fee components (amounts in crypto)","properties":{"platform_fee":{"type":"string","description":"Platform fee in crypto"},"platform_fee_pct":{"type":"number","format":"double","description":"Platform fee as a percentage"},"partner_fee":{"type":"string","description":"Your markup in crypto, computed from the `partner_fee_pct` you declared on the\nquote/estimate request, on the same base as the platform fee. `0` when no markup was declared.\n"},"partner_fee_pct":{"type":"number","format":"double","description":"Your markup percentage as declared on the request (`1` means 1%); `0` when none."},"total_fee":{"type":"string","description":"Total fee in crypto (platform_fee + partner_fee)"}}},"PartnerErrorResponse":{"type":"object","required":["success","error"],"properties":{"success":{"type":"boolean"},"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Machine-readable error code. Possible values:\n- `QUOTE_EXPIRED` — quote TTL has passed\n- `QUOTE_NOT_FOUND` — quote ID does not exist\n- `QUOTE_ALREADY_USED` — quote was already consumed by initiate\n- `PRICE_CHANGED_REQUOTE` — price changed beyond slippage tolerance, partner should request a new quote\n- `ORDER_NOT_FOUND` — order ID does not exist or belongs to another partner\n- `NO_OFFERS_AVAILABLE` — no vendor offers for the requested pair\n- `INVALID_REQUEST` — malformed request or missing fields\n- `INVALID_PAYMENT_DETAILS` — payment details not found or invalid\n- `RAIL_ROUTE_MISMATCH` — rail or route does not match payment details\n- `UNAUTHORIZED` — authentication failed\n- `INTERNAL_ERROR` — unexpected server error\n- `INVALID_STATUS` — order is not in the correct status for the requested action\n- `TRANSACTOR_ERROR` — on-chain transaction relay failed (e.g. simulation reverted)\n- `INSUFFICIENT_BALANCE` — partner wallet lacks tokens to fund escrow\n- `OPERATION_NOT_ALLOWED` — action is not permitted for the current order state\n- `KYC_NOT_CLEARED` — the customer is not KYC-cleared for this transaction; `error.details.kyc_status` carries their current partner-facing status\n- `SENDER_IDENTITY_REQUIRED` — the payout corridor settles consumer-to-consumer and the customer's record cannot name them as the remitter; `error.details.kyc_fields` lists what to send to `PATCH /partner/users/{user_uuid}/kyc`, and `error.details.field_limits` carries the rail's length ceiling on any listed field that has one\n\nFiat account endpoints (`/partner/fiat-accounts/config` and the `/partner/users/{user_uuid}`\n`identity`, `identification` and `fiat-accounts` routes) answer with their own codes, each\ndescribed on the response that returns it: `FIAT_ACCOUNT_NOT_FOUND`,\n`ACCOUNT_HOLDER_NOT_FOUND`, `ACCOUNT_NOT_PROVISIONED`, `CUSTOMER_NOT_FOUND`,\n`CUSTOMER_NOT_VERIFIED`, `CLIENT_NOT_APPROVED`, `HOLDER_REGISTRATION_IN_PROGRESS`,\n`IDENTIFICATION_MISSING`, `IDENTIFICATION_ALREADY_LINKED`, `INVALID_DOCUMENT_TYPE`,\n`POSTAL_CODE_REQUIRED`, `INVALID_POSTAL_CODE`, `MISSING_FIELDS`, `PRODUCT_NOT_ACTIVATED`,\n`ISSUANCE_NOT_GRANTED`, `ISSUANCE_DISABLED`, `PROVISIONING_IN_PROGRESS`,\n`UNSUPPORTED_CURRENCY`, `UNSUPPORTED_ISSUER_COUNTRY`, `CURRENCY_NOT_PERMITTED`,\n`CURRENCY_NOT_PRICED`, `RECORD_FAILED`, `BANKING_ERROR`, `BANKING_UNAVAILABLE`.\n\nThis list is not closed: codes are added as endpoints grow. Treat a code you do not recognise\nas an error of the kind its HTTP status says, rather than rejecting the response.\n"},"message":{"type":"string","description":"Human-readable error message"},"details":{"type":"object","nullable":true,"additionalProperties":true,"description":"Additional context (e.g., quoted_rate, current_rate for slippage errors)"}}}}}}}}
```

## Get off-ramp quote

> Get a price quote for an off-ramp operation (crypto to fiat). Returns the exchange rate,\
> amounts, and a quote ID that can be used to initiate the order. Quotes expire after 60 seconds.\
> \
> The \`user\_uuid\` is the public UUID returned by \`POST /partner/users\` (the \`id\` field).\
> \
> \*\*Choose exactly one payout ownership mode:\*\*\
> \- Self-payout: provide \`payment\_details\_id\` for an account owned by \`user\_uuid\`.\
> \- Third-party payout: omit \`payment\_details\_id\` and provide \`sender\_id\`, \`recipient\_id\`,\
> &#x20; \`recipient\_destination\_id\`, \`sender\_recipient\_relationship\`, and \`purpose\_of\_payment\`.\
> \
> For a third-party payout, \`sender\_id\` must identify the same real KYC subject as \`user\_uuid\`.\
> At quote time Unigox validates tenant ownership, sender eligibility, destination currency/rail,\
> recipient screening, relationship, and purpose. It persists an immutable canonical recipient\
> snapshot on the quote. Updating or archiving the recipient afterwards does not mutate the quote;\
> request a new quote to use changed details.\
> \
> On \`/initiate\`, Unigox revalidates the quote snapshot and creates a durable compliance case.\
> In v1 there is no partner-visible review state machine: every control is enforced BEFORE the\
> order exists, so a payout that fails one is rejected with an error code rather than returned\
> as a held order. An order that was created was authorized (\`compliance.status: authorized\`,\
> \`risk\_decision: allow\`, \`requires\_review: false\`). Operator review happens on the Unigox side\
> over the same records; API-created third-party payouts use the same Compliance queue as\
> portal-created payouts.\
> \
> \*\*Anchor mode — provide exactly one of \`crypto\_amount\` or \`fiat\_amount\`:\*\*\
> \
> \| Field | Anchor | Guarantee |\
> \|---|---|---|\
> \| \`crypto\_amount\` | Crypto-anchored | Exact crypto cost; fiat payout varies by the rate at settlement |\
> \| \`fiat\_amount\` | Fiat-anchored | Exact fiat delivery to recipient; crypto cost may vary within a 1.20× safety bound |\
> \
> The response \`anchor\_type\` field tells you which side is guaranteed: \`"crypto"\` or \`"fiat"\`.\
> \
> \*\*Fiat-anchored notes:\*\*\
> \- Useful for exact-payout use cases (e.g. deliver exactly 500,000 NGN).\
> \- If the crypto cost we quote exceeds 1.20× the expected amount, the order moves to \`failed\` status\
> &#x20; and the deposit is automatically refunded from escrow back to the partner wallet. Create a new quote\
> &#x20; to retry.\
> \- \`slippage\_tolerance.type=amount\` in fiat-anchored mode is evaluated as crypto-side delta, not fiat-side\
> &#x20; (fiat is always exact by construction).\
> \- Integer-minor-unit currencies (NGN, UGX, TZS, KES, GHS): \`fiat\_amount\` must be a whole number.\
> \
> \*\*Fee structure:\*\* The \`fee\_breakdown\` object shows all fee components transparently:\
> \`platform\_fee\` (our fee in crypto), \`partner\_fee\` (your per-order markup in crypto — declare it\
> by passing \`partner\_fee\_pct\` on the quote/estimate request, where \`1\` means 1%), \`partner\_fee\_pct\`\
> (the percentage applied), and \`total\_fee\` (sum of both). The partner fee is computed on the same\
> crypto base as the platform fee (non-compounding) and is settled on-chain to your partner wallet\
> on successful release. An order that does not settle is never charged the markup, and the refund\
> returns the full funded amount with the markup in it. Note that the refund is not automatic once\
> the escrow has been funded: the crypto stays there until you sign for it — see\
> \`POST /orders/{order\_id}/authorize-refund\`.\
> \
> Apply your fee on the \*\*same side\*\* the platform does, or your receipts won't reconcile with our\
> orders. \`crypto\_amount\` in responses and webhooks stays partner-fee-exclusive, with two\
> exceptions: a crypto-anchored onramp order (request declares \`crypto\_amount\`) is sized up so the\
> delivered amount stays exact after the markup is withheld, so \`crypto\_amount\` there includes the\
> markup; a crypto-anchored offramp order (request declares \`crypto\_amount\`) carves both fees out of\
> the pinned deposit, so \`crypto\_amount\` and \`platform\_fee\` both shrink relative to a no-markup\
> request even though the markup itself stays out of \`crypto\_amount\`. Every other case is unaffected\
> — the markup shows only in \`fee\_breakdown\` (and in the funding amount returned by transfer\
> authorization for offramp — \`crypto\_amount + fee\_breakdown.partner\_fee\` is what you actually fund).\
> Fiat-anchored offramp orders have no cap on the markup — the deposit grows to cover it. Every\
> other case hits a formula domain bound, not a business cap: onramp rejects \`partner\_fee\_pct >= 100\`\
> (the withheld or grossed-up fee would consume the entire buyer amount, leaving nothing delivered);\
> crypto-anchored offramp rejects it when the fee would consume the entire pinned deposit.\
> \
> \*\*Address requirement for EUR/AUD/GBP:\*\* EUR, AUD, and GBP payouts require the\
> user's physical address (street, city, postal code). If the user's\
> KYC does not include address data, the quote response will include a \`beneficiary\_requirements\`\
> field indicating which fields are needed. You can update the user's KYC with\
> \`PATCH /partner/users/{user\_uuid}/kyc\` to provide the address before or after initiating the order.\
> If you initiate without address, the order will be created with status \`pending\_address\` until\
> the address is provided.\
> \
> \*\*KYC requirement:\*\* The customer must be KYC-cleared before you can quote or transact for them.\
> If the customer is not cleared, this endpoint returns \`422 KYC\_NOT\_CLEARED\` and\
> \`error.details.kyc\_status\` carries their current partner-facing KYC status. The same check is\
> re-applied as the order moves toward payout. A customer still in compliance review is reported as\
> \`IN\_PROGRESS\` — treat \`IN\_PROGRESS\` as "not cleared yet" and keep polling\
> \`GET /api/v1/partner/users/{user\_uuid}/verification-status\`.<br>

```json
{"openapi":"3.0.3","info":{"title":"Unigox API Gateway","version":"1.0.0"},"tags":[{"name":"Off-Ramp","description":"Off-ramp operations (crypto to fiat) — sell crypto and receive fiat"}],"servers":[{"url":"https://api-staging.unigox.com","description":"Sandbox server"},{"url":"https://api.unigox.com","description":"Production server"}],"security":[{"ApiKeyAuth":[]}],"components":{"securitySchemes":{"ApiKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key","description":"Partner API key for authentication. Required for all partner account endpoints."}},"schemas":{"PartnerQuoteRequest":{"type":"object","required":["user_uuid","crypto_currency","fiat_currency"],"allOf":[{"oneOf":[{"required":["crypto_amount"]},{"required":["fiat_amount"]}]},{"oneOf":[{"required":["payment_details_id"]},{"required":["sender_id","recipient_id","recipient_destination_id","sender_recipient_relationship","purpose_of_payment"]}]}],"properties":{"user_uuid":{"type":"string","format":"uuid","description":"Public UUID of the end-user (returned by `POST /partner/users` as `id`)"},"payment_details_id":{"type":"integer","format":"int64","description":"Required for a self-payout. MUST be omitted for a third-party payout, which is routed by `recipient_destination_id`. Sending both is rejected with `400 INVALID_REQUEST` — the two modes are mutually exclusive and neither silently wins."},"sender_id":{"type":"string","format":"uuid","description":"Canonical KYC subject who is funding the third-party payout. Required for third-party\npayout and must equal the real sender represented by `user_uuid`.\n"},"recipient_id":{"type":"string","format":"uuid","description":"Partner-owned recipient returned by `POST /partner/recipients`."},"recipient_destination_id":{"type":"string","format":"uuid","description":"Active destination owned by `recipient_id`; replaces `payment_details_id` for third-party payout."},"sender_recipient_relationship":{"type":"string","minLength":1,"description":"Payment-level relationship between the real sender and recipient, for example `supplier` or `family`."},"purpose_of_payment":{"type":"string","minLength":1,"description":"Canonical payment purpose used for compliance and translated only at the vendor boundary."},"purpose_details":{"type":"string","description":"Optional invoice or free-text clarification of the payment purpose."},"crypto_currency":{"type":"string","description":"Cryptocurrency code (e.g., USDT, USDC)"},"crypto_amount":{"type":"string","description":"Amount of crypto to sell (standard units, e.g., \"100.00\" for 100 USDT).\nProvide exactly one of `crypto_amount` or `fiat_amount`.\nWhen provided, the order is **crypto-anchored** — exact crypto cost is guaranteed, fiat payout varies by rate at settlement.\n"},"fiat_amount":{"type":"string","description":"Exact fiat amount the recipient will receive (e.g., \"500000\" for 500,000 NGN).\nProvide exactly one of `crypto_amount` or `fiat_amount`.\nWhen provided, the order is **fiat-anchored** — exact fiat delivery is guaranteed, crypto cost may vary within a 1.20× safety bound.\nFor integer-minor-unit currencies (NGN, UGX, TZS, KES, GHS), must be a whole number.\n"},"fiat_currency":{"type":"string","description":"Target fiat currency code (e.g., NGN, USD, EUR)"},"rail":{"type":"string","description":"Optional rail hint; must be compatible with payment details if provided"},"route":{"type":"string","description":"Optional route hint; must be compatible with payment details if provided"},"provider_scope":{"type":"string","description":"Optional provider scope filter; defaults to all","enum":["all","licensed_only","p2p_only"]},"slippage_tolerance":{"allOf":[{"$ref":"#/components/schemas/SlippageTolerance"}],"description":"Optional. Maximum tolerated price movement between `/quote` and `/initiate`,\nand between `/initiate` and vendor acceptance. When omitted, the default is\n`percent` / `0` — any rate movement returns `PRICE_CHANGED_REQUOTE` and the\npartner must request a fresh quote. Set this to opt in to silent execution\nfor small market movements.\n"},"has_fiat_settlement_notification":{"type":"boolean","description":"When `true`, only match liquidity providers that send fiat settlement\nnotifications. Orders from these providers complete automatically when\nfiat is delivered. When omitted or `false`, all providers are eligible.\n"},"partner_fee_pct":{"type":"string","pattern":"^[0-9]+(\\.[0-9]+)?$","description":"Optional per-order markup you charge your end user, applied on the same crypto base\nas the platform fee (non-compounding). Percent units: `1` means 1%. Omitting the field\nfrom this request payload — or sending `0` — means no markup; the markup cannot be\nnegative. Fiat-anchored offramp orders have no cap — the deposit grows to cover the\nmarkup. Every other case hits a formula domain bound, not a business cap: onramp\nrejects `partner_fee_pct >= 100` (the withheld or grossed-up fee would consume the\nentire buyer amount, leaving nothing delivered); crypto-anchored offramp rejects it\nwhen the fee would consume the entire pinned deposit.\n\nNote: you send this value as a JSON **string**, but it round-trips as a JSON **number**\nin `fee_breakdown.partner_fee_pct` and in the webhook `data.partner_fee_pct` field —\ndon't parse those as strings.\n\n**Rejection reference:**\n\n| Rejection | `error.code` | HTTP status |\n|---|---|---|\n| `partner_fee_pct` is non-numeric, negative, `NaN`, or `Inf` | `INVALID_REQUEST` | 400 |\n| Onramp: fee would consume the entire buyer amount (`partner_fee_pct >= 100`) | `INVALID_REQUEST` | 400 |\n| Crypto-anchored offramp: fee would consume the entire pinned deposit | `INVALID_REQUEST` | 400 |\n| Fiat-anchored offramp: the computed fee is not a finite amount | `INVALID_REQUEST` | 400 |\n| Crypto-anchored offramp: the carved fee-exclusive gross falls outside the matched offer's min/max | `NO_OFFERS_AVAILABLE` | 409 |\n","default":"0"}}},"SlippageTolerance":{"type":"object","properties":{"type":{"type":"string","enum":["percent","amount","fiat"],"description":"Tolerance type:\n- `percent` — percentage of the quoted rate (e.g., 1.5 = 1.5%)\n- `amount` — absolute fiat amount (e.g., 50.00 = 50 NGN)\n- `fiat` — alias of `amount`\n"},"value":{"type":"number","format":"double","description":"Tolerance value (percentage or fiat amount depending on type)"}}},"APIResponse":{"type":"object","required":["success"],"properties":{"success":{"type":"boolean","description":"Indicates if the request was successful"},"data":{"description":"Response data (structure varies by endpoint) or error information if success is false"}}},"PartnerQuoteResponse":{"type":"object","properties":{"quote_id":{"type":"string","format":"uuid","description":"Unique quote identifier — use this to initiate the order"},"anchor_type":{"type":"string","enum":["crypto","fiat"],"description":"Indicates which amount is guaranteed at settlement.\n- `crypto` — `crypto_amount` is exact; `fiat_amount` may vary by the rate at settlement.\n- `fiat` — `fiat_amount` is exact; `crypto_amount` may vary within a 1.20× safety bound.\n"},"rate":{"type":"string","description":"Crypto-to-fiat exchange rate at quote time"},"crypto_amount":{"type":"string","description":"Total crypto amount — the fee-exclusive gross: the sell amount plus the platform fee.\nPartner-fee-exclusive: your markup is reported separately in `fee_breakdown.partner_fee` and\nis not added here. This is not the total you fund: the amount you actually fund at initiation\nis `crypto_amount + fee_breakdown.partner_fee` (see the transfer authorization endpoint).\n"},"fiat_amount":{"type":"string","description":"Fiat amount the recipient receives"},"fee_breakdown":{"$ref":"#/components/schemas/FeeBreakdown"},"payment_details_id":{"type":"integer","format":"int64","description":"Payment details ID used for this quote"},"payment_method_id":{"type":"integer","format":"int64","description":"Matched payment method ID"},"payment_network_id":{"type":"integer","format":"int64","description":"Matched payment network ID"},"expires_at":{"type":"string","format":"date-time","description":"Quote expiration timestamp (30 seconds from creation)"},"has_fiat_settlement_notification":{"type":"boolean","description":"Whether the matched liquidity provider sends fiat settlement notifications.\nWhen `true`, the order will complete automatically when fiat is delivered\nto the recipient. When `false`, you must call `confirm-fiat-received`.\nYou can call `confirm-fiat-received` at any time regardless of this value.\n"},"recipient_context":{"allOf":[{"$ref":"#/components/schemas/PartnerRecipientContext"}],"nullable":true,"description":"Immutable canonical recipient snapshot for a third-party quote; absent for self-payout."}}},"FeeBreakdown":{"type":"object","description":"Transparent breakdown of all fee components (amounts in crypto)","properties":{"platform_fee":{"type":"string","description":"Platform fee in crypto"},"platform_fee_pct":{"type":"number","format":"double","description":"Platform fee as a percentage"},"partner_fee":{"type":"string","description":"Your markup in crypto, computed from the `partner_fee_pct` you declared on the\nquote/estimate request, on the same base as the platform fee. `0` when no markup was declared.\n"},"partner_fee_pct":{"type":"number","format":"double","description":"Your markup percentage as declared on the request (`1` means 1%); `0` when none."},"total_fee":{"type":"string","description":"Total fee in crypto (platform_fee + partner_fee)"}}},"PartnerRecipientContext":{"type":"object","required":["sender_id","recipient_id","recipient_destination_id","sender_recipient_relationship","purpose_of_payment","screening_status"],"properties":{"sender_id":{"type":"string","format":"uuid"},"recipient_id":{"type":"string","format":"uuid"},"recipient_destination_id":{"type":"string","format":"uuid"},"sender_recipient_relationship":{"type":"string"},"purpose_of_payment":{"type":"string"},"purpose_details":{"type":"string"},"screening_status":{"type":"string"}}},"PartnerErrorResponse":{"type":"object","required":["success","error"],"properties":{"success":{"type":"boolean"},"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Machine-readable error code. Possible values:\n- `QUOTE_EXPIRED` — quote TTL has passed\n- `QUOTE_NOT_FOUND` — quote ID does not exist\n- `QUOTE_ALREADY_USED` — quote was already consumed by initiate\n- `PRICE_CHANGED_REQUOTE` — price changed beyond slippage tolerance, partner should request a new quote\n- `ORDER_NOT_FOUND` — order ID does not exist or belongs to another partner\n- `NO_OFFERS_AVAILABLE` — no vendor offers for the requested pair\n- `INVALID_REQUEST` — malformed request or missing fields\n- `INVALID_PAYMENT_DETAILS` — payment details not found or invalid\n- `RAIL_ROUTE_MISMATCH` — rail or route does not match payment details\n- `UNAUTHORIZED` — authentication failed\n- `INTERNAL_ERROR` — unexpected server error\n- `INVALID_STATUS` — order is not in the correct status for the requested action\n- `TRANSACTOR_ERROR` — on-chain transaction relay failed (e.g. simulation reverted)\n- `INSUFFICIENT_BALANCE` — partner wallet lacks tokens to fund escrow\n- `OPERATION_NOT_ALLOWED` — action is not permitted for the current order state\n- `KYC_NOT_CLEARED` — the customer is not KYC-cleared for this transaction; `error.details.kyc_status` carries their current partner-facing status\n- `SENDER_IDENTITY_REQUIRED` — the payout corridor settles consumer-to-consumer and the customer's record cannot name them as the remitter; `error.details.kyc_fields` lists what to send to `PATCH /partner/users/{user_uuid}/kyc`, and `error.details.field_limits` carries the rail's length ceiling on any listed field that has one\n\nFiat account endpoints (`/partner/fiat-accounts/config` and the `/partner/users/{user_uuid}`\n`identity`, `identification` and `fiat-accounts` routes) answer with their own codes, each\ndescribed on the response that returns it: `FIAT_ACCOUNT_NOT_FOUND`,\n`ACCOUNT_HOLDER_NOT_FOUND`, `ACCOUNT_NOT_PROVISIONED`, `CUSTOMER_NOT_FOUND`,\n`CUSTOMER_NOT_VERIFIED`, `CLIENT_NOT_APPROVED`, `HOLDER_REGISTRATION_IN_PROGRESS`,\n`IDENTIFICATION_MISSING`, `IDENTIFICATION_ALREADY_LINKED`, `INVALID_DOCUMENT_TYPE`,\n`POSTAL_CODE_REQUIRED`, `INVALID_POSTAL_CODE`, `MISSING_FIELDS`, `PRODUCT_NOT_ACTIVATED`,\n`ISSUANCE_NOT_GRANTED`, `ISSUANCE_DISABLED`, `PROVISIONING_IN_PROGRESS`,\n`UNSUPPORTED_CURRENCY`, `UNSUPPORTED_ISSUER_COUNTRY`, `CURRENCY_NOT_PERMITTED`,\n`CURRENCY_NOT_PRICED`, `RECORD_FAILED`, `BANKING_ERROR`, `BANKING_UNAVAILABLE`.\n\nThis list is not closed: codes are added as endpoints grow. Treat a code you do not recognise\nas an error of the kind its HTTP status says, rather than rejecting the response.\n"},"message":{"type":"string","description":"Human-readable error message"},"details":{"type":"object","nullable":true,"additionalProperties":true,"description":"Additional context (e.g., quoted_rate, current_rate for slippage errors)"}}}}}}},"paths":{"/api/v1/partner/offramp/quote":{"post":{"tags":["Off-Ramp"],"summary":"Get off-ramp quote","description":"Get a price quote for an off-ramp operation (crypto to fiat). Returns the exchange rate,\namounts, and a quote ID that can be used to initiate the order. Quotes expire after 60 seconds.\n\nThe `user_uuid` is the public UUID returned by `POST /partner/users` (the `id` field).\n\n**Choose exactly one payout ownership mode:**\n- Self-payout: provide `payment_details_id` for an account owned by `user_uuid`.\n- Third-party payout: omit `payment_details_id` and provide `sender_id`, `recipient_id`,\n  `recipient_destination_id`, `sender_recipient_relationship`, and `purpose_of_payment`.\n\nFor a third-party payout, `sender_id` must identify the same real KYC subject as `user_uuid`.\nAt quote time Unigox validates tenant ownership, sender eligibility, destination currency/rail,\nrecipient screening, relationship, and purpose. It persists an immutable canonical recipient\nsnapshot on the quote. Updating or archiving the recipient afterwards does not mutate the quote;\nrequest a new quote to use changed details.\n\nOn `/initiate`, Unigox revalidates the quote snapshot and creates a durable compliance case.\nIn v1 there is no partner-visible review state machine: every control is enforced BEFORE the\norder exists, so a payout that fails one is rejected with an error code rather than returned\nas a held order. An order that was created was authorized (`compliance.status: authorized`,\n`risk_decision: allow`, `requires_review: false`). Operator review happens on the Unigox side\nover the same records; API-created third-party payouts use the same Compliance queue as\nportal-created payouts.\n\n**Anchor mode — provide exactly one of `crypto_amount` or `fiat_amount`:**\n\n| Field | Anchor | Guarantee |\n|---|---|---|\n| `crypto_amount` | Crypto-anchored | Exact crypto cost; fiat payout varies by the rate at settlement |\n| `fiat_amount` | Fiat-anchored | Exact fiat delivery to recipient; crypto cost may vary within a 1.20× safety bound |\n\nThe response `anchor_type` field tells you which side is guaranteed: `\"crypto\"` or `\"fiat\"`.\n\n**Fiat-anchored notes:**\n- Useful for exact-payout use cases (e.g. deliver exactly 500,000 NGN).\n- If the crypto cost we quote exceeds 1.20× the expected amount, the order moves to `failed` status\n  and the deposit is automatically refunded from escrow back to the partner wallet. Create a new quote\n  to retry.\n- `slippage_tolerance.type=amount` in fiat-anchored mode is evaluated as crypto-side delta, not fiat-side\n  (fiat is always exact by construction).\n- Integer-minor-unit currencies (NGN, UGX, TZS, KES, GHS): `fiat_amount` must be a whole number.\n\n**Fee structure:** The `fee_breakdown` object shows all fee components transparently:\n`platform_fee` (our fee in crypto), `partner_fee` (your per-order markup in crypto — declare it\nby passing `partner_fee_pct` on the quote/estimate request, where `1` means 1%), `partner_fee_pct`\n(the percentage applied), and `total_fee` (sum of both). The partner fee is computed on the same\ncrypto base as the platform fee (non-compounding) and is settled on-chain to your partner wallet\non successful release. An order that does not settle is never charged the markup, and the refund\nreturns the full funded amount with the markup in it. Note that the refund is not automatic once\nthe escrow has been funded: the crypto stays there until you sign for it — see\n`POST /orders/{order_id}/authorize-refund`.\n\nApply your fee on the **same side** the platform does, or your receipts won't reconcile with our\norders. `crypto_amount` in responses and webhooks stays partner-fee-exclusive, with two\nexceptions: a crypto-anchored onramp order (request declares `crypto_amount`) is sized up so the\ndelivered amount stays exact after the markup is withheld, so `crypto_amount` there includes the\nmarkup; a crypto-anchored offramp order (request declares `crypto_amount`) carves both fees out of\nthe pinned deposit, so `crypto_amount` and `platform_fee` both shrink relative to a no-markup\nrequest even though the markup itself stays out of `crypto_amount`. Every other case is unaffected\n— the markup shows only in `fee_breakdown` (and in the funding amount returned by transfer\nauthorization for offramp — `crypto_amount + fee_breakdown.partner_fee` is what you actually fund).\nFiat-anchored offramp orders have no cap on the markup — the deposit grows to cover it. Every\nother case hits a formula domain bound, not a business cap: onramp rejects `partner_fee_pct >= 100`\n(the withheld or grossed-up fee would consume the entire buyer amount, leaving nothing delivered);\ncrypto-anchored offramp rejects it when the fee would consume the entire pinned deposit.\n\n**Address requirement for EUR/AUD/GBP:** EUR, AUD, and GBP payouts require the\nuser's physical address (street, city, postal code). If the user's\nKYC does not include address data, the quote response will include a `beneficiary_requirements`\nfield indicating which fields are needed. You can update the user's KYC with\n`PATCH /partner/users/{user_uuid}/kyc` to provide the address before or after initiating the order.\nIf you initiate without address, the order will be created with status `pending_address` until\nthe address is provided.\n\n**KYC requirement:** The customer must be KYC-cleared before you can quote or transact for them.\nIf the customer is not cleared, this endpoint returns `422 KYC_NOT_CLEARED` and\n`error.details.kyc_status` carries their current partner-facing KYC status. The same check is\nre-applied as the order moves toward payout. A customer still in compliance review is reported as\n`IN_PROGRESS` — treat `IN_PROGRESS` as \"not cleared yet\" and keep polling\n`GET /api/v1/partner/users/{user_uuid}/verification-status`.\n","operationId":"getOffRampQuote","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerQuoteRequest"}}}},"responses":{"200":{"description":"Quote retrieved successfully","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/APIResponse"},{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PartnerQuoteResponse"}}}]}}}},"400":{"description":"Bad request — invalid parameters or missing required fields","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerErrorResponse"}}}},"401":{"description":"Unauthorized — invalid or missing API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerErrorResponse"}}}},"404":{"description":"User or payment details not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerErrorResponse"}}}},"409":{"description":"No offers available for the requested pair","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerErrorResponse"}}}},"422":{"description":"The customer is not KYC-cleared. `error.details.kyc_status` carries the customer's current partner-facing KYC status — one of `IN_PROGRESS`, `UNDER_REVIEW`, `PENDING_DOCUMENTS`, `VERIFICATION_REJECTED`, or `NOT_INITIATED`. `UNDER_REVIEW` means a manual compliance review (up to 24h); keep the customer waiting and re-check. The message stays generic.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerErrorResponse"}}}}}}}}}
```

## Initiate off-ramp order

> Initiate an off-ramp order using a previously obtained quote. The quote must still be active\
> (not expired or already used). The system refreshes the price and checks slippage tolerance\
> before creating the order.\
> \
> If the price has moved beyond the slippage tolerance, a \`PRICE\_CHANGED\_REQUOTE\` error is returned\
> with details about the old and new rates, so the partner can request a new quote.\
> \
> On success, returns the created order with status \`awaiting\_liquidity\_provider\`.\
> \
> \*\*Address requirement for EUR/AUD/GBP:\*\* For EUR/AUD/GBP orders, if the\
> user's KYC does not include a physical address, the order will be created with status\
> \`pending\_address\`. The response will include \`required\_fields: \["address", "city", "postal\_code"]\`\
> and \`next\_action: "provide\_address"\`. Call \`PATCH /partner/users/{user\_uuid}/kyc\` to add the\
> address, and the order will automatically advance.\
> \
> \*\*Recommended flow for EUR/AUD/GBP:\*\*\
> 1\. Submit KYC with address/city/postal\_code (\`POST /kyc-submissions\`)\
> 2\. Create payment details (\`POST /payment-details\`) — address auto-populated from KYC\
> 3\. Get quote (\`POST /offramp/quote\`)\
> 4\. Initiate (\`POST /offramp/initiate\`) — proceeds without delays\
> \
> \*\*KYC requirement:\*\* The customer must be KYC-cleared, exactly as described on\
> \`POST /offramp/quote\`. If they are not, this endpoint returns \`422 KYC\_NOT\_CLEARED\` with the\
> customer's partner-facing status in \`error.details.kyc\_status\`. The check is re-applied as the\
> order moves toward payout.<br>

```json
{"openapi":"3.0.3","info":{"title":"Unigox API Gateway","version":"1.0.0"},"tags":[{"name":"Off-Ramp","description":"Off-ramp operations (crypto to fiat) — sell crypto and receive fiat"}],"servers":[{"url":"https://api-staging.unigox.com","description":"Sandbox server"},{"url":"https://api.unigox.com","description":"Production server"}],"security":[{"ApiKeyAuth":[]}],"components":{"securitySchemes":{"ApiKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key","description":"Partner API key for authentication. Required for all partner account endpoints."}},"schemas":{"PartnerInitiateRequest":{"type":"object","required":["quote_id"],"properties":{"quote_id":{"type":"string","format":"uuid","description":"Quote ID from the quote endpoint. Payment details are taken from the quote."},"reference_id":{"type":"string","description":"Optional partner-supplied external reference ID (e.g. your internal order ID). Stored on the order and echoed back in responses."}}},"APIResponse":{"type":"object","required":["success"],"properties":{"success":{"type":"boolean","description":"Indicates if the request was successful"},"data":{"description":"Response data (structure varies by endpoint) or error information if success is false"}}},"PartnerInitiateResponse":{"type":"object","properties":{"order_id":{"type":"string","format":"uuid","description":"Unique order identifier for tracking"},"status":{"$ref":"#/components/schemas/PartnerOrderStatus"},"quote_id":{"type":"string","format":"uuid","description":"Quote ID that was consumed"},"reference_id":{"type":"string","nullable":true,"description":"Partner-supplied external reference ID (echoed from initiate request)"},"order_type":{"type":"string","enum":["offramp","onramp"],"description":"Direction of the order"},"amounts":{"$ref":"#/components/schemas/PartnerOrderAmounts"},"fee_breakdown":{"$ref":"#/components/schemas/FeeBreakdown"},"timeline":{"type":"array","items":{"$ref":"#/components/schemas/TimelineEntry"}},"created_at":{"type":"string","format":"date-time"},"recipient_context":{"allOf":[{"$ref":"#/components/schemas/PartnerRecipientContext"}],"nullable":true},"compliance":{"allOf":[{"$ref":"#/components/schemas/PartnerComplianceCase"}],"nullable":true,"description":"Durable case shown in Compliance admin for third-party payout."}}},"PartnerOrderStatus":{"type":"string","description":"Order status.\n\n**Off-ramp statuses (crypto → fiat):**\n- `created` — order created, quote locked\n- `awaiting_liquidity_provider` — order initiated, waiting for vendor acceptance\n- `awaiting_crypto_transfer_authorization` — partner must authorize crypto transfer via EIP-712 signature\n- `crypto_transfer_authorization_pending` — authorization submitted, awaiting on-chain confirmation\n- `crypto_received` — crypto locked in escrow\n- `fiat_payment_started` — buyer submitted fiat payment proof\n- `awaiting_fiat_received_confirmation` — waiting for partner to confirm fiat receipt\n- `fiat_received_confirmed` — fiat confirmed, crypto release pending\n- `crypto_release_pending` — escrow release in progress\n- `completed` — order fully completed\n- `cancelled` — order cancelled\n- `failed` — terminal error\n- `price_changed_requote_needed` — slippage exceeded at initiation (not a trade status)\n\n**On-ramp statuses (fiat → crypto):**\n- `awaiting_liquidity_provider` — order initiated, waiting for vendor acceptance\n- `awaiting_vendor_escrow_funding` — vendor accepted, funding escrow with crypto\n- `awaiting_fiat_transfer` — crypto locked in escrow; the partner pays the vendor and confirms payment sent (`fiat_funding_source: vendor_details`), or the customer transfers into their own fiat account (`own_iban`)\n- `fiat_transfer_pending` — fiat payment confirmed, awaiting vendor confirmation of receipt\n- `completed` — vendor confirmed fiat received, crypto released to end-user wallet\n- `cancelled` — order cancelled\n- `failed` — escrow error\n\n**On-ramp send-out statuses (optional post-trade cross-chain transfer):**\n\n> After an on-ramp order reaches `completed`, the partner may optionally initiate a\n> **send-out** — a cross-chain transfer of the received crypto to a destination network\n> (e.g. XAI → Arbitrum). This is an additional flow and does not affect the core trade lifecycle.\n> The `completed` status reflects that the trade itself is done; send-out statuses track\n> the subsequent cross-chain transfer only.\n\n- `send_out_pending` — send-out initiated, cross-chain transfer in progress\n- `send_out_completed` — cross-chain transfer successfully delivered to destination\n- `send_out_failed` — cross-chain transfer failed; the on-ramp trade itself remains completed\n","enum":["created","awaiting_liquidity_provider","awaiting_crypto_transfer_authorization","crypto_transfer_authorization_pending","crypto_received","fiat_payment_started","awaiting_fiat_received_confirmation","fiat_received_confirmed","crypto_release_pending","awaiting_vendor_escrow_funding","awaiting_fiat_transfer","fiat_transfer_pending","completed","cancelled","failed","price_changed_requote_needed","send_out_pending","send_out_completed","send_out_failed"]},"PartnerOrderAmounts":{"type":"object","properties":{"crypto_amount":{"type":"string","description":"Total crypto amount (standard units) — the fee-exclusive gross: the buy/sell amount plus the\nplatform fee. Partner-fee-exclusive, except for a crypto-anchored onramp order (originally\nquoted with `crypto_amount`): there the amount was sized up so the delivered amount stays\nexact after the partner fee is withheld, so this field includes the markup. A crypto-anchored\nofframp order carves both fees out of the pinned deposit instead, so this field and\n`fee_breakdown.platform_fee` both shrink relative to a no-markup request, even though the\nmarkup itself stays out of this field. Every other case is unaffected — the markup is\nreported separately in `fee_breakdown.partner_fee`.\n\nOn-ramp — what the end user receives: `crypto_amount − fee_breakdown.platform_fee −\nfee_breakdown.partner_fee` for a fiat-anchored order, or exactly this `crypto_amount` for a\ncrypto-anchored order (both fees are withheld at bridge send-out without changing this\nfield). Off-ramp — what the partner funds at transfer authorization:\n`crypto_amount + fee_breakdown.partner_fee`.\n"},"fiat_amount":{"type":"string","description":"Fiat amount the user receives"},"crypto_currency":{"type":"string","description":"Cryptocurrency code"},"fiat_currency":{"type":"string","description":"Fiat currency code"},"rate":{"type":"string","description":"Crypto-to-fiat exchange rate"}}},"FeeBreakdown":{"type":"object","description":"Transparent breakdown of all fee components (amounts in crypto)","properties":{"platform_fee":{"type":"string","description":"Platform fee in crypto"},"platform_fee_pct":{"type":"number","format":"double","description":"Platform fee as a percentage"},"partner_fee":{"type":"string","description":"Your markup in crypto, computed from the `partner_fee_pct` you declared on the\nquote/estimate request, on the same base as the platform fee. `0` when no markup was declared.\n"},"partner_fee_pct":{"type":"number","format":"double","description":"Your markup percentage as declared on the request (`1` means 1%); `0` when none."},"total_fee":{"type":"string","description":"Total fee in crypto (platform_fee + partner_fee)"}}},"TimelineEntry":{"type":"object","properties":{"status":{"$ref":"#/components/schemas/PartnerOrderStatus"},"timestamp":{"type":"string","format":"date-time","description":"When this status was reached"},"description":{"type":"string","description":"Human-readable description of the status change"}}},"PartnerRecipientContext":{"type":"object","required":["sender_id","recipient_id","recipient_destination_id","sender_recipient_relationship","purpose_of_payment","screening_status"],"properties":{"sender_id":{"type":"string","format":"uuid"},"recipient_id":{"type":"string","format":"uuid"},"recipient_destination_id":{"type":"string","format":"uuid"},"sender_recipient_relationship":{"type":"string"},"purpose_of_payment":{"type":"string"},"purpose_details":{"type":"string"},"screening_status":{"type":"string"}}},"PartnerComplianceCase":{"type":"object","required":["case_id","status","risk_decision","requires_review"],"description":"The compliance record for a third-party payout. In v1 the controls run BEFORE the order is\ncreated, so a payout that fails one is rejected with an error rather than returned as a held\norder — see the error codes in the Third-party payouts guide.\n","properties":{"case_id":{"type":"string","format":"uuid","description":"Identifier of the compliance record — the payout's `quote_id`, so a partner and a Unigox\noperator refer to the same record.\n"},"status":{"type":"string","enum":["authorized"],"description":"v1 emits `authorized`; a rejected payout returns an error instead."},"risk_decision":{"type":"string","enum":["allow"],"description":"v1 emits `allow`; a blocking decision surfaces as an error, not a status."},"requires_review":{"type":"boolean","description":"Always false in v1. Operator review happens Unigox-side over the same record."}}},"PartnerErrorResponse":{"type":"object","required":["success","error"],"properties":{"success":{"type":"boolean"},"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string","description":"Machine-readable error code. Possible values:\n- `QUOTE_EXPIRED` — quote TTL has passed\n- `QUOTE_NOT_FOUND` — quote ID does not exist\n- `QUOTE_ALREADY_USED` — quote was already consumed by initiate\n- `PRICE_CHANGED_REQUOTE` — price changed beyond slippage tolerance, partner should request a new quote\n- `ORDER_NOT_FOUND` — order ID does not exist or belongs to another partner\n- `NO_OFFERS_AVAILABLE` — no vendor offers for the requested pair\n- `INVALID_REQUEST` — malformed request or missing fields\n- `INVALID_PAYMENT_DETAILS` — payment details not found or invalid\n- `RAIL_ROUTE_MISMATCH` — rail or route does not match payment details\n- `UNAUTHORIZED` — authentication failed\n- `INTERNAL_ERROR` — unexpected server error\n- `INVALID_STATUS` — order is not in the correct status for the requested action\n- `TRANSACTOR_ERROR` — on-chain transaction relay failed (e.g. simulation reverted)\n- `INSUFFICIENT_BALANCE` — partner wallet lacks tokens to fund escrow\n- `OPERATION_NOT_ALLOWED` — action is not permitted for the current order state\n- `KYC_NOT_CLEARED` — the customer is not KYC-cleared for this transaction; `error.details.kyc_status` carries their current partner-facing status\n- `SENDER_IDENTITY_REQUIRED` — the payout corridor settles consumer-to-consumer and the customer's record cannot name them as the remitter; `error.details.kyc_fields` lists what to send to `PATCH /partner/users/{user_uuid}/kyc`, and `error.details.field_limits` carries the rail's length ceiling on any listed field that has one\n\nFiat account endpoints (`/partner/fiat-accounts/config` and the `/partner/users/{user_uuid}`\n`identity`, `identification` and `fiat-accounts` routes) answer with their own codes, each\ndescribed on the response that returns it: `FIAT_ACCOUNT_NOT_FOUND`,\n`ACCOUNT_HOLDER_NOT_FOUND`, `ACCOUNT_NOT_PROVISIONED`, `CUSTOMER_NOT_FOUND`,\n`CUSTOMER_NOT_VERIFIED`, `CLIENT_NOT_APPROVED`, `HOLDER_REGISTRATION_IN_PROGRESS`,\n`IDENTIFICATION_MISSING`, `IDENTIFICATION_ALREADY_LINKED`, `INVALID_DOCUMENT_TYPE`,\n`POSTAL_CODE_REQUIRED`, `INVALID_POSTAL_CODE`, `MISSING_FIELDS`, `PRODUCT_NOT_ACTIVATED`,\n`ISSUANCE_NOT_GRANTED`, `ISSUANCE_DISABLED`, `PROVISIONING_IN_PROGRESS`,\n`UNSUPPORTED_CURRENCY`, `UNSUPPORTED_ISSUER_COUNTRY`, `CURRENCY_NOT_PERMITTED`,\n`CURRENCY_NOT_PRICED`, `RECORD_FAILED`, `BANKING_ERROR`, `BANKING_UNAVAILABLE`.\n\nThis list is not closed: codes are added as endpoints grow. Treat a code you do not recognise\nas an error of the kind its HTTP status says, rather than rejecting the response.\n"},"message":{"type":"string","description":"Human-readable error message"},"details":{"type":"object","nullable":true,"additionalProperties":true,"description":"Additional context (e.g., quoted_rate, current_rate for slippage errors)"}}}}}}},"paths":{"/api/v1/partner/offramp/initiate":{"post":{"tags":["Off-Ramp"],"summary":"Initiate off-ramp order","description":"Initiate an off-ramp order using a previously obtained quote. The quote must still be active\n(not expired or already used). The system refreshes the price and checks slippage tolerance\nbefore creating the order.\n\nIf the price has moved beyond the slippage tolerance, a `PRICE_CHANGED_REQUOTE` error is returned\nwith details about the old and new rates, so the partner can request a new quote.\n\nOn success, returns the created order with status `awaiting_liquidity_provider`.\n\n**Address requirement for EUR/AUD/GBP:** For EUR/AUD/GBP orders, if the\nuser's KYC does not include a physical address, the order will be created with status\n`pending_address`. The response will include `required_fields: [\"address\", \"city\", \"postal_code\"]`\nand `next_action: \"provide_address\"`. Call `PATCH /partner/users/{user_uuid}/kyc` to add the\naddress, and the order will automatically advance.\n\n**Recommended flow for EUR/AUD/GBP:**\n1. Submit KYC with address/city/postal_code (`POST /kyc-submissions`)\n2. Create payment details (`POST /payment-details`) — address auto-populated from KYC\n3. Get quote (`POST /offramp/quote`)\n4. Initiate (`POST /offramp/initiate`) — proceeds without delays\n\n**KYC requirement:** The customer must be KYC-cleared, exactly as described on\n`POST /offramp/quote`. If they are not, this endpoint returns `422 KYC_NOT_CLEARED` with the\ncustomer's partner-facing status in `error.details.kyc_status`. The check is re-applied as the\norder moves toward payout.\n","operationId":"initiateOffRamp","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerInitiateRequest"}}}},"responses":{"201":{"description":"Off-ramp order created successfully","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/APIResponse"},{"type":"object","properties":{"data":{"$ref":"#/components/schemas/PartnerInitiateResponse"}}}]}}}},"400":{"description":"Bad request — missing required fields","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerErrorResponse"}}}},"401":{"description":"Unauthorized — invalid or missing API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerErrorResponse"}}}},"404":{"description":"Quote not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerErrorResponse"}}}},"409":{"description":"Quote expired, already used, slippage exceeded, or — for a third-party payout — the payout agent has not confirmed recipient-context support, in which case no order is created and the quote is left untouched.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerErrorResponse"}}}},"422":{"description":"The request is well-formed but the party is not payable yet. No order is created and the quote is reverted, so the same customer can retry once the cause is fixed.\n`KYC_NOT_CLEARED` — the customer is not KYC-cleared. `error.details.kyc_status` carries their current partner-facing status: `IN_PROGRESS`, `UNDER_REVIEW`, `PENDING_DOCUMENTS`, `VERIFICATION_REJECTED`, or `NOT_INITIATED`. `UNDER_REVIEW` means a manual compliance review (up to 24h); keep the customer waiting and re-check. The message stays generic.\n`THIRD_PARTY_CONTEXT_INVALID` — the frozen third-party recipient context is no longer usable (archived recipient, screening no longer `cleared`, or a destination that stopped resolving).\n`SENDER_IDENTITY_REQUIRED` — the corridor settles consumer-to-consumer (Chinese mobile wallets: the remitter shown on the receiving wallet has to be the paying customer, not Unigox) and their record cannot name them. `error.details.missing_fields` is what the rail needs; `error.details.kyc_fields` is the subset you can supply yourself, as the request body of `PATCH /partner/users/{user_uuid}/kyc`. When `kyc_fields` is shorter than `missing_fields`, the remainder (the name on the document, the country of residence) can only come from verification itself — the customer has to complete or redo KYC. Send the customer's own details: substituting anyone else's is what the rail's identity check exists to catch.\nA field is also listed when the record holds a value the rail would refuse for its length: the rail rejects the whole payment, at payout, after the customer has funded, so it is refused here while a retry is still free. `error.details.field_limits` is the vendor's ceiling on any listed field that has one, keyed the way you write it in the KYC patch and counted in characters, not bytes; it is absent when no listed field has one. Read the ceilings from there rather than hard-coding them — they differ per corridor.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartnerErrorResponse"}}}}}}}}}
```
