{"openapi":"3.1.0","info":{"title":"KTAP Partner API","version":"1.0.0","summary":"Cards for your customers, funded in crypto.","description":"Order cards for your users and put money on them. You keep your own signup, your own accounts and your\nown support; KTAP issues the card and settles the spend.\n\n**The card.** A virtual Visa — there is no physical card. Cardholders can add it to Google Pay and pay\ncontactless in stores; Apple Wallet is NOT supported, so do not promise it to iPhone users. The\ncardholder name is printed in uppercase, at most 26 characters: send it as the user wrote it —\naccents with an ASCII equivalent convert automatically (José becomes Jose), äöüÄÖÜ are kept, O'Brien\nneeds no change, and only ß, Ø, Æ and non-Latin scripts are refused. Online\npurchases that trigger 3-D Secure are confirmed with a one-time code sent by SMS to the phone number the\ncard was ordered with — one more reason that number must be a real, reachable mobile. If your product\nwould rather deliver the code yourself (email, in-app) than by SMS, talk to us — webhook delivery of 3DS\ncodes is available per program and we will enable it with you.\n\n**Funding.** The model is a prepaid float. You collect payment from your users yourselves, at your own\naddresses and on your own rails; we never touch that side. You fund your float by reading your funding\naddress from `GET /funding/addresses`, sending USDC or USDT there on Polygon, BNB Chain or Base (at least\n10 USD per transfer), and submitting the transaction hash to `POST /funding`: it is verified on-chain and\ncredited, minus the load fee on your agreement, once the funds arrive. Only a direct transfer from a\nwallet you registered with us before sending is credited automatically. Other networks are settled by\narrangement with us. You then assign balance to cards with `POST /cards/{cardId}/topup`.\n\n`POST /deposits` is a separate, OPTIONAL route where we hand your user a deposit address and the card\ncredits itself when the transfer confirms. It is enabled per agreement and needs the `deposits:*` scopes;\nunless your key has them, skip that section.\n\n**Fees.** Your rates are agreed with us and returned by `GET /fees` — read them from the API rather\nthan hardcoding. Loading from your prepaid float costs less than a load your user pays by deposit, and\ncard usage (purchases, FX, ATM, high-risk merchants, Visa Direct) is charged as it happens.\n\nA monthly card fee also applies to **every card that exists**, used or not, for any\nmonth it was alive: no proration, and there is NO separate issuance fee. Ordering a card pays its first\nmonth, and each further month it stays open costs one more. Settle it from your balance or by invoice,\nas agreed; that choice governs this fee only. Closing cards you no longer need via\n`POST /cards/{cardId}/close` stops the charge and returns any balance left on them.\n\n**Spending.** A cardholder can make unlimited purchases, but no single purchase may exceed $5,000 USD. A larger purchase is declined at the point of sale and arrives as a `card.declined` event; split it into amounts under $5,000. This caps spending only — a card may hold more than $5,000.\n\nYou may set your own markup on top with `PUT /fees`. Your share accrues as commission — visible under\n`earnings`, paid out on request, and kept apart from the float so it can never be spent by mistake. The\nmarkup applies to deposits only: in the float model you have already collected from your user, so\ncharging it again would double-bill them.\n\n**Keys** are issued by us — there is no self-serve signup, and no sandbox. Every key is live: calls\nreach the real card processor, cards consume a finite allowance and top-ups move real money. A key can\norder cards and spend your balance, so keep it server-side.\n\n**Rate limit.** The default is 120 requests per minute per key; your key's actual allowance is\nreturned by GET /me as `rate_limit_per_minute` — read it from there rather than hardcoding, since\nhigher allowances are provisioned per program. Responses from a handler carry\n`X-RateLimit-Limit` and\n`X-RateLimit-Remaining`; over the limit you get 429 with code `RATE_LIMITED` — back off and retry. The\nlimit is a throughput guard, not a card-ordering budget: your total cards are capped separately by your\ncontracted allowance.","contact":{"name":"KTAP","email":"dev@ktap.io"}},"servers":[{"url":"https://virtual.ktap.io/api/partner/v1","description":"Production"}],"security":[{"ApiKeyAuth":[]},{"BearerAuth":[]}],"tags":[{"name":"Account","description":"Check your key, fund your balance and see what is left to spend."},{"name":"Cards","description":"Order cards, fund them, freeze them."},{"name":"Deposits","description":"Optional, enabled per agreement: let a user pay in crypto and credit their card automatically."},{"name":"Webhooks","description":"Tell us where to send events."}],"components":{"securitySchemes":{"ApiKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key"},"BearerAuth":{"type":"http","scheme":"bearer","description":"Same key, if your client prefers it here."}},"schemas":{"Error":{"type":"object","required":["error"],"properties":{"error":{"type":"string","description":"What went wrong, in plain English."},"code":{"type":"string","description":"Branch on this, not on the message text.","enum":["UNAUTHORIZED","FORBIDDEN","NOT_FOUND","INSUFFICIENT_FUNDS","ALREADY_PROCESSED","ALREADY_SUBMITTED","ALREADY_CREDITED","RATE_LIMITED","TOO_MANY_PENDING","QUOTA","INVALID_NAME","INVALID_EMAIL","INVALID_PHONE","PHONE_COUNTRY_NOT_ALLOWED","UPSTREAM_AUTH","UNAVAILABLE","BAD_REQUEST"]}},"example":{"error":"This key is missing the required scope: cards:write","code":"FORBIDDEN"}},"Card":{"type":"object","properties":{"id":{"type":"string","description":"KTAP card id. Use it in every other call.","example":"7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u"},"partner_ref":{"type":["string","null"],"description":"Your own identifier for the cardholder, echoed back untouched.","example":"your-user-8842"},"card_holder_name":{"type":"string","example":"JANE SMITH"},"last4":{"type":["string","null"],"example":"7640"},"status":{"type":["string","null"],"description":"ACTIVE, FROZEN or DELETED (closed) in normal use. NULL briefly before the first status sync. Other processor values (e.g. BLOCKED after repeated failed 3DS) can appear on the raw column — treat any value other than ACTIVE as \"not currently spendable\" rather than switching exhaustively.","enum":["ACTIVE","FROZEN","DELETED","BLOCKED","INACTIVE",null],"example":"ACTIVE"},"created_at":{"type":"string","format":"date-time"}}},"Transaction":{"type":"object","properties":{"fee_breakdown":{"type":"object","description":"The fee itemization the processor provides: fx, atm and deposit components. USD-purchase pricing is applied via the exchange rate and appears within fx_usd. The three sum EXACTLY to provider_fee_usd, so an equality assertion holds. Amounts and processor fee fields on transaction rows are the card processor's figures rounded to the cent, each fee component rounded separately, so a fee under half a cent reads 0. client_fee_usd is computed from those figures and not rounded again. The exact fee taken at authorization is on the purchase's card.authorized webhook. Store amounts in a decimal type and do not round before summing.","properties":{"fx_usd":{"type":"number"},"atm_usd":{"type":"number"},"deposit_usd":{"type":"number"}}},"id":{"type":"string","description":"This row's own movement id. Stable, but it is NOT the id webhooks carry: join webhooks on transaction_id."},"transaction_id":{"type":["string","null"],"description":"The purchase id. Equal to transaction_id on the card.authorized, card.transaction and card.declined webhooks for the same purchase, so this is the key that pairs pulled history with delivered events."},"datetime":{"type":"string","description":"UTC timestamp WITHOUT an offset suffix, e.g. 2026-09-16T14:16:21.204 — append Z when parsing. (Earlier integrations were told this was processor-local; it is UTC.)"},"status":{"type":"string","enum":["pending","settled","declined","refunded","partially_refunded","reversed","partially_reversed"],"description":"pending = authorized but not yet settled. The amount has already been taken from the card, fee included, but the merchant can still settle a different amount, and settlement typically takes many hours, so a recent purchase is normally pending. settled = the merchant has settled, including when it settled more or less than it authorized. partially_refunded/partially_reversed mean only PART of the purchase came back; a partial reversal followed by a settlement of the rest reads settled, with amount_usd lowered."},"merchant":{"type":["string","null"]},"amount_usd":{"type":"number","description":"The purchase's current total as the card processor reports it, fees included, to the cent. It is not zeroed on a declined row, where nothing left the card. A reversal lowers it."},"original_amount":{"type":["number","null"],"description":"The amount in the ORIGINAL currency of the purchase, not USD. Pair it with original_currency; never treat it as a dollar figure."},"original_currency":{"type":["string","null"]},"fee_basis_usd":{"type":"number","description":"amount_usd less the fees inside it, in USD (never original_amount, which is in the original currency). Both are rounded to the cent, so it can be a cent away from what the merchant charged. Zero on a declined transaction."},"exchange_rate":{"type":["number","null"]},"provider_fee_usd":{"type":"number","description":"The fees charged on this transaction, as the card processor itemizes them in fee_breakdown. Zero on a declined transaction. Amounts and processor fee fields on transaction rows are the card processor's figures rounded to the cent, each fee component rounded separately, so a fee under half a cent reads 0. client_fee_usd is computed from those figures and not rounded again. The exact fee taken at authorization is on the purchase's card.authorized webhook. Store amounts in a decimal type and do not round before summing."},"client_fee_usd":{"type":"number","description":"Your markup on this transaction: fee_basis_usd multiplied by your configured transaction markup. It is not part of amount_usd. Zero unless a markup is configured."},"fee_usd":{"type":"number","description":"provider_fee_usd + client_fee_usd, exactly. ZERO on a declined transaction: nothing settled, so nothing was charged. provider_fee_usd is rounded to the cent; client_fee_usd is not."},"decline_reason":{"type":["object","null"],"description":"Set when status is declined. `code` is a CLOSED KTAP enum — branch on it; `message` is safe to show a cardholder. `retryable` means the same purchase can succeed on a straight retry with no action; `card_blocked` means the card is unusable until we unblock it (contact us).","properties":{"code":{"type":"string","enum":["insufficient_card_balance","issuer_funds_unavailable","daily_limit_exceeded","weekly_limit_exceeded","monthly_limit_exceeded","single_purchase_limit_exceeded","velocity_limit_exceeded","card_blocked","fraud_risk_declined","security_check_failed","merchant_category_blocked","processing_error","authorization_timeout","unknown"]},"message":{"type":"string","description":"Cardholder-safe explanation. Always present."},"retryable":{"type":"boolean"},"card_blocked":{"type":"boolean"}}}}},"FeesSummary":{"type":"object","description":"The load-fee summary embedded in GET /me and returned by PUT /fees. GET /fees has the full schedule.","properties":{"ktap_pct":{"type":"number","example":0},"your_markup_pct":{"type":"number","example":0},"total_pct":{"type":"number","example":0},"example_on_100_usd":{"type":"object","properties":{"ktap_fee_usd":{"type":"number"},"your_fee_usd":{"type":"number"},"lands_on_card_usd":{"type":"number"}}}}},"Fees":{"type":"object","description":"What comes off a deposit before it reaches the card. The KTAP portion is fixed; your markup is yours, and accrues as commission we pay out on request.","properties":{"ktap_pct":{"type":"number","description":"Your load fee as a fraction. On the prepaid-float model it is charged once, when you send us funds — not on each card top-up.","example":0.01},"load_from_float_pct":{"type":"number","description":"The same figure, named explicitly."},"load_from_user_deposit_pct":{"type":"number","description":"Only applies if your end user pays us in crypto directly."},"transaction":{"type":"object","description":"What card USAGE costs. Read these rather than hardcoding them.","properties":{"usd_purchase_pct":{"type":"number"},"your_markup_pct":{"type":"number","description":"Your own markup on card transactions, charged on top of the rates here and reported per transaction as client_fee_usd."},"fx_purchase_pct":{"type":"number"},"high_risk_surcharge_pct":{"type":"number","description":"ADDED to the purchase rate, not instead of it."},"atm_pct":{"type":"number"},"visa_direct_pct":{"type":"number"},"monthly_per_card_usd":{"type":"number","description":"Per card per month, for every card that EXISTS — including frozen ones. Closing a card is what stops it."}}},"your_markup_pct":{"type":"number","description":"Your own rate, set with PUT /fees. Earnings from it appear under `earnings`.","example":0},"total_pct":{"type":"number","description":"Deducted in total.","example":0},"example_on_100_usd":{"type":"object","description":"The same rates worked through on $100, so you need not do the arithmetic.","properties":{"ktap_fee_usd":{"type":"number","example":1},"your_fee_usd":{"type":"number","example":2},"lands_on_card_usd":{"type":"number","example":97}}}}},"Earnings":{"type":"object","description":"Commission earned from your markup. Deliberately separate from your float: this is a payable we settle by transfer when you ask for it, never a balance you can spend on cards.","properties":{"accrued_usd":{"type":"number","description":"Everything earned, ever.","example":412.5},"paid_out_usd":{"type":"number","description":"Everything already paid to you.","example":300},"owed_usd":{"type":"number","description":"Outstanding. Contact us to withdraw.","example":112.5},"last_payout_at":{"type":["string","null"],"format":"date-time"}}},"Float":{"type":"object","description":"What you can spend. Balance is the money you have sent us; limit is a ceiling we set, which may be lower. Whichever binds first is available_usd, and that is what a top-up is checked against.","properties":{"funded_usd":{"type":"number","description":"Everything you have sent us.","example":10000},"spent_usd":{"type":"number","description":"Card top-ups and fees drawn down.","example":550},"balance_usd":{"type":"number","description":"funded − spent.","example":9450},"limit_usd":{"type":["number","null"],"description":"Ceiling we set. Null means none, in which case available equals balance.","example":2000},"available_usd":{"type":"number","description":"Spendable right now: the smaller of balance and remaining limit.","example":1450}}},"DepositQuote":{"type":"object","description":"The quote returned by POST /deposits: an address and the amount to ask your user for. It has no status yet because nothing has been paid.","properties":{"deposit_id":{"type":"string","format":"uuid"},"card_id":{"type":"string"},"asset":{"type":"string","example":"USDC-BEP20"},"chain":{"type":"string","example":"BNB Chain"},"deposit_address":{"type":"string","example":"0x9f2c…"},"send_amount":{"type":"string","description":"What to ask your user for. On EVM and Bitcoin the card has its own address, so a round number is fine. On Solana there is no per-card address and the amount is what identifies the payment — see exact_amount_required.","example":"100.00"},"rate_usd":{"type":"number"},"fee_usd":{"type":"number","description":"Everything deducted: the standard load fee plus your markup."},"ktap_fee_usd":{"type":"number","description":"KTAP's share of fee_usd."},"partner_fee_usd":{"type":"number","description":"Your markup. Accrues to your commission balance when the deposit lands."},"card_credit_usd":{"type":"number","description":"What lands on the card once it confirms."},"exact_amount_required":{"type":"boolean","description":"True when the figure must match to the digit (Solana). False when the address belongs to this card alone and any amount credits it."},"expires_at":{"type":"string","format":"date-time"}}},"DepositRecord":{"type":"object","description":"A deposit as listed by GET /deposits: the persisted record, with its lifecycle status and, once paid, the settling on-chain transaction.","properties":{"id":{"type":"string","format":"uuid"},"card_id":{"type":"string"},"asset":{"type":"string","example":"USDC-BEP20"},"chain":{"type":"string","example":"BNB Chain"},"deposit_address":{"type":"string","example":"0x9f2c…"},"amount_expected":{"type":"string","description":"The amount the user was asked to send.","example":"100.00"},"rate_usd":{"type":"number"},"fee_usd":{"type":"number","description":"Everything deducted: the standard load fee plus your markup."},"credit_usd":{"type":"number","description":"What lands (or landed) on the card."},"status":{"type":"string","enum":["pending","credited","expired","cancelled"]},"tx_hash":{"type":["string","null"],"description":"The settling on-chain transaction once credited; null while pending."},"expires_at":{"type":"string","format":"date-time"},"credited_at":{"type":["string","null"],"format":"date-time","description":"When the deposit credited the card; null while pending."},"created_at":{"type":"string","format":"date-time"}}},"Funding":{"type":"object","description":"One transaction hash you submitted with POST /funding, and where it stands. Each transaction can be credited once.","properties":{"id":{"type":"string","format":"uuid","description":"Use it with GET /funding/{fundingId}. It is funding_id on the float.credited webhook."},"network":{"type":"string","enum":["POLYGON","BNB","BASE"],"description":"The network the hash is checked on. BSC is accepted on input and stored as BNB. It can be corrected while the submission is unverified (see POST /funding), and is fixed once the transaction is verified."},"tx_hash":{"type":"string","pattern":"^0x[0-9a-f]{64}$","description":"The transaction hash, lowercased."},"status":{"type":"string","enum":["pending","credited","rejected","review"],"description":"pending = received. Either the transaction has not been found on its network yet (it is re-checked every couple of minutes for 24 hours, then rejected as not_found), or it is verified and waiting for the funds to arrive. credited = added to your balance: GET /me reflects it, float.credited is sent, and GET /ledger has a funding row referenced funding-<NETWORK>-<tx_hash> and its fee row with :fee appended. rejected = cannot be credited as submitted, or a credit we reversed; reason says why. Only not_found can be corrected, by submitting the hash again with the right network. review = verified, but not credited automatically: a person decides, and reason says why. We resolve it: it then becomes credited (float.credited is sent and the same ledger rows are written), credited with reason credited_separately (the funds were credited to your balance by other means: fee_usd and credited_usd stay null, amount_usd is still the amount sent, and no webhook is sent), or rejected with reason rejected_by_operator. There is nothing to resubmit. A credited submission that was not your money can be reversed by us: it becomes rejected with reason reversed_by_operator, GET /ledger gains a negative funding row referenced funding-reversal-<NETWORK>-<tx_hash> and a positive fee row with :fee appended (the load fee is returned), your balance drops by credited_usd (it can go negative), and no webhook is sent."},"reason":{"type":["string","null"],"enum":["transaction_failed","not_a_funding_transfer","unsupported_token","mixed_tokens","below_minimum","bridge_fill","before_automatic_funding","before_account_opened","not_found","rejected_by_operator","reversed_by_operator","precision","multiple_senders","operator_sender","unregistered_sender","routed_transfer","unlisted_token","not_arrived","ambiguous_match","amount_mismatch","receipt_changed","not_final","check_failed","already_credited","held_by_operator","credited_separately",null],"description":"Set when status is rejected or review, or credited separately; null otherwise. Whose money it is is decided first, then the amount. Rejected: transaction_failed (it failed on-chain), not_a_funding_transfer (nothing was transferred to your funding address in that transaction on that network), unsupported_token (a transfer reached your funding address, but in a token that is not accepted on that network), mixed_tokens (it sent both USDC and USDT to your funding address), below_minimum (less than 10 USD, which is never picked up), unregistered_sender (the only wallet that paid is registered to another account, so it is that account's money; wallets registered to other accounts are rejected regardless of whether your account is set to accept any wallet), bridge_fill (on any network, the transaction delivered bridged funds into the funding address, the arrival of a deposit made elsewhere; the depositor claims their own transaction on the network they sent from, and if you bridged the funds yourself, contact us with both hashes), before_automatic_funding (sent before automatic funding started; those transfers were settled separately), before_account_opened (it predates your account), not_found (not found on that network within 24 hours of your first submission, or still unreadable a day later; submitting it again looks it up again), rejected_by_operator (we reviewed it and it cannot be credited), reversed_by_operator (it was credited and we reversed the credit because it was not your money). Review: unregistered_sender (the wallet that sent it is registered to no account; this does not arise if your account is set to accept any wallet, unless the wallet is registered to another account which is rejected instead), operator_sender (one of our own wallets paid into the funding address in that transaction), multiple_senders (more than one wallet paid into the funding address in that transaction, even when one of them is registered to another account), routed_transfer (the wallet did not pay directly: the transfer was gasless, bundled (ERC-4337) or relayed, or routed through another contract such as a router, batcher, bridge or solver), precision (the amount has more than 6 decimal places), unlisted_token (that token is not listed on that network at the moment), not_arrived (verified, but no matching arrival within 72 hours of the transaction), ambiguous_match (on Polygon, more than one arrival was reported for the same transaction), amount_mismatch (the arrival reported for it differs from what the chain shows in token, amount or sending wallet), receipt_changed (on BNB Chain or Base, the transaction is no longer on its chain as it was verified, or has been missing from it for over 2 hours), not_final (on BNB Chain or Base, an arrival matched but the transaction did not become final on its network within 6 hours), check_failed (the chain could not be read for this submission long after its window), already_credited (the same funds were already credited to a balance by other means), held_by_operator (we held it, your account is suspended, or your account was credited for the same transaction before and that credit was reversed). Credited: credited_separately (after a review, the funds were credited to your balance by other means)."},"token":{"type":["string","null"],"enum":["USDC","USDT",null],"description":"The stablecoin sent. Null until the transaction has been read on-chain."},"amount_usd":{"type":["number","null"],"description":"What the transaction paid into your funding address, as read on-chain: always the amount sent, even when a review credits less. Several transfers of one token in the same transaction are summed. The ledger keeps up to 10 decimal places; an amount with more than 6 goes to review with reason precision rather than being rounded. Null until verified."},"fee_usd":{"type":["number","null"],"description":"The load fee on your agreement, charged on the amount credited: amount_usd, or after a review no more than arrived if less arrived. The same figure as fee_usd on float.credited. Null until credited, and on a credited_separately submission. A reversal keeps it."},"credited_usd":{"type":["number","null"],"description":"The amount credited minus fee_usd: what reached your balance, the same figure as credited_usd on float.credited. Null until credited, and on a credited_separately submission. A reversal keeps it and takes this amount back off your balance."},"created_at":{"type":"string","format":"date-time","description":"When this submission was first recorded. A network correction keeps it."},"credited_at":{"type":["string","null"],"format":"date-time","description":"When it was credited; null until then."}}}},"responses":{"Unauthorized":{"description":"Key missing, unknown, revoked, or the account is suspended.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Forbidden":{"description":"Valid key without the scope this endpoint needs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"NotFound":{"description":"No such card under your account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"RateLimited":{"description":"Too many requests this minute. Wait for Retry-After.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"parameters":{"IdempotencyKey":{"name":"Idempotency-Key","in":"header","required":false,"schema":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,128}$","maxLength":128},"description":"Letters, digits, hyphen and underscore only, 1-128 characters — anything else is rejected with 400. Colons are NOT allowed, so use order-8842 rather than order:8842. Send one on anything that creates a card or moves money. A retry with the same key returns the ORIGINAL result — including the original fee — instead of doing the work twice. If the first call is still in flight you get 409 ALREADY_PROCESSED; retry in a moment. If it was rejected and your float refunded, you also get 409 — that key is spent, so use a new one. Optional at the transport level (the API accepts a call without one), but on POST /cards/{cardId}/topup a missing key means a retry after a timeout can debit your float TWICE — always send one there.","example":"order-8842"}}},"paths":{"/me":{"get":{"tags":["Account"],"operationId":"getMe","x-scope":"users:read","summary":"Identify this key","description":"The account behind the key, its scopes, live or test, and your float broken down. Worth calling first when wiring up an integration, and again whenever you need to show a remaining balance.","security":[{"ApiKeyAuth":[]}],"responses":{"200":{"description":"Key details.","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Requests allowed per minute."},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Left in the current minute."}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"partner":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":["string","null"]},"contact_email":{"type":["string","null"]},"created_at":{"type":["string","null"],"format":"date-time"}}},"key":{"type":"object","properties":{"name":{"type":["string","null"],"description":"Your label for this key."},"prefix":{"type":"string","description":"The ktap_live_xxxxxxxx prefix. Quote this, never the full key."},"scopes":{"type":"array","items":{"type":"string"}},"mode":{"type":"string","enum":["live","test"]},"rate_limit_per_minute":{"type":"integer"}}},"cards":{"type":"object","properties":{"total":{"type":"integer","description":"Every card ever ordered, whatever its status — the same number the allowance is enforced against. Remaining allowance = limit − total."},"active":{"type":"integer","description":"Cards currently open (not closed)."},"limit":{"type":["integer","null"],"description":"Your lifetime card allowance, or null when no cap is set on your account. Closing a card does NOT return capacity. Ordering past it fails with 409 QUOTA — contact us to raise it."}}},"float":{"$ref":"#/components/schemas/Float"},"fees":{"$ref":"#/components/schemas/FeesSummary"},"earnings":{"$ref":"#/components/schemas/Earnings"}}}}},"example":{"data":{"partner":{"id":"d9fc7f97","name":"Acme Ltd","contact_email":"ops@acme.com","created_at":"2026-01-15T09:00:00Z"},"key":{"name":"server-prod","prefix":"ktap_live_1a2b3c4d","scopes":["cards:read","cards:write"],"mode":"live","rate_limit_per_minute":120},"cards":{"total":12,"active":9,"limit":null},"float":{"funded_usd":10000,"spent_usd":550,"balance_usd":9450,"limit_usd":2000,"available_usd":1450},"fees":{"ktap_pct":0.01,"your_markup_pct":0.02,"total_pct":0.03,"example_on_100_usd":{"ktap_fee_usd":1,"your_fee_usd":2,"lands_on_card_usd":97}},"earnings":{"accrued_usd":412.5,"paid_out_usd":300,"owed_usd":112.5,"last_payout_at":"2026-08-01T10:00:00Z"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/fees":{"get":{"tags":["Account"],"operationId":"getFees","x-scope":"users:read","summary":"Current fee rates","description":"The KTAP charge, your markup, and the total, with the same rates worked through on $100.","responses":{"200":{"description":"Rates in force.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Fees"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"}}},"put":{"tags":["Account"],"operationId":"setMarkup","x-scope":"users:write","summary":"Set your markup","description":"Sets the rate you charge on top of KTAP, as a fraction — 0.025 means a 2.5 percent markup. Applies to new deposits\nimmediately; existing pending deposits keep the rate they were quoted at.\n\nCapped at 0.5. Sending `2.5` when you meant a 2.5 percent markup would consume the entire deposit and leave nothing\non the card, so it is rejected rather than honoured.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["markup_pct"],"properties":{"markup_pct":{"type":"number","minimum":0,"maximum":0.5,"description":"0.025 means a 2.5 percent markup"}}},"example":{"markup_pct":0.025}}}},"responses":{"200":{"description":"Updated rates — the SAME full shape as GET /fees (markup applied), not the summary.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Fees"}}}}}},"400":{"description":"markup_pct missing, not a number, or outside 0-0.5.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/cards":{"get":{"tags":["Cards"],"operationId":"listCards","x-scope":"cards:read","summary":"List your cards","description":"Newest first. Only cards belonging to your account are ever returned.","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","default":50,"maximum":100}},{"name":"offset","in":"query","schema":{"type":"integer","default":0}}],"responses":{"200":{"description":"Your cards.","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Requests allowed per minute."},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Left in the current minute."}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"cards":{"type":"array","items":{"$ref":"#/components/schemas/Card"}},"count":{"type":"integer"}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"}}},"post":{"tags":["Cards"],"operationId":"issueCard","x-scope":"cards:write","summary":"Order a card","description":"Issues a card for one of your users. Cards come from a finite LIFETIME allowance (see `cards.limit` on GET /me — closing a card does not return capacity), so send an `Idempotency-Key`: a retry then returns the card you already ordered rather than burning another. Spending limits: the cardholder can make unlimited purchases with no daily, weekly or monthly cap, but NO SINGLE PURCHASE may exceed $5,000 USD — a larger one is declined at the point of sale and arrives as a `card.declined` event. This caps spending only; the card may hold more than $5,000.","parameters":[{"$ref":"#/components/parameters/IdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["card_holder_name","phone_country_code","phone","email"],"properties":{"card_holder_name":{"type":"string","maxLength":26,"description":"Printed on the card, uppercased by the issuer. Accepted: letters, digits, the letters äöüÄÖÜ, space, and / - ^ . ' ) — the right parenthesis is accepted, the left is not. At most 26 characters, measured after normalization. Send the name as the user wrote it: accented letters with an ASCII equivalent convert automatically (José becomes Jose, Łukasz becomes Lukasz) and äöüÄÖÜ are kept as they are. ß, Ø, Æ and non-Latin scripts such as Cyrillic or CJK have no equivalent and are rejected with 400 — for those, send an ASCII form yourself. Read card_holder_name back from the response to see what was stored."},"phone_country_code":{"type":"integer","example":1},"phone":{"type":"string","pattern":"^\\d{4,15}$","description":"Subscriber number only — digits, no country code (that is phone_country_code), no +, spaces or dashes. The 4-15 digit pattern is the floor WE enforce; clearing it is necessary but not sufficient. The card processor then validates the number for real and rejects placeholders like 0000000 and impossible ranges with 400, so send a genuine mobile number.","example":"5551234567"},"email":{"type":"string","format":"email","description":"The cardholder's email."},"partner_ref":{"type":"string","description":"Your id for this user. Returned on the card and on every webhook about it."}}},"example":{"card_holder_name":"JANE SMITH","phone_country_code":1,"phone":"5551234567","email":"jane@yoursite.com","partner_ref":"your-user-8842"}}}},"responses":{"201":{"description":"Card issued.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"card":{"$ref":"#/components/schemas/Card"}}}}}}}},"400":{"description":"The cardholder details or the request were refused. The code says why: INVALID_NAME, INVALID_EMAIL, INVALID_PHONE (including a number the card processor refuses as invalid), PHONE_COUNTRY_NOT_ALLOWED (cards are not issued to numbers with that country code), or BAD_REQUEST for a malformed body or any other refusal. Nothing was created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"409":{"description":"Two cases, told apart by `code`: QUOTA — EITHER your lifetime card allowance is used up (closing cards does not return capacity; talk to us about raising it) OR platform issuing capacity is temporarily full (separate from your allowance; do not retry, contact us). In both, nothing was created and no allowance was consumed. ALREADY_PROCESSED — a card order with this Idempotency-Key is still in flight, retry shortly.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"$ref":"#/components/responses/RateLimited"},"502":{"description":"The card processor could not be reached or errored. Retry shortly — 5xx means OUR side, never yours.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Upstream authentication problem on our side — retry shortly. Code UPSTREAM_AUTH.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/cards/{cardId}":{"parameters":[{"name":"cardId","in":"path","required":true,"schema":{"type":"string"}}],"get":{"tags":["Cards"],"operationId":"getCard","x-scope":"cards:read","summary":"Get a card","description":"Card details plus its spendable balance, read live from the processor rather than cached.","responses":{"200":{"description":"Card and balance.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"card":{"$ref":"#/components/schemas/Card"},"balance_usd":{"type":["number","null"],"description":"Live from the processor. NULL means unreachable — unknown, never zero."}}}}},"example":{"data":{"card":{"id":"7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u","last4":"7640","status":"ACTIVE"},"balance_usd":136.43}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"502":{"description":"The card processor could not be reached or errored. Retry shortly — 5xx means OUR side, never yours.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/cards/{cardId}/topup":{"parameters":[{"name":"cardId","in":"path","required":true,"schema":{"type":"string"}}],"post":{"tags":["Cards"],"operationId":"topUpCard","x-scope":"cards:write","summary":"Move float onto a card","description":"Takes `amount_usd` from your float and credits the card 1:1 — no fee is applied here, because the load fee is charged when you fund your float. Checked against `available_usd` — the smaller of your balance and any limit we have set — and refused with 402 rather than going negative. Use this when you collected the money yourself; use `POST /deposits` when the user is paying in crypto. Loading has no per-purchase cap — a card can hold more than $5,000 — but SPENDING is capped at $5,000 per single purchase (see POST /cards). ALWAYS send an `Idempotency-Key` here: without one, a retry after a timeout can debit your float twice.","parameters":[{"$ref":"#/components/parameters/IdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["amount_usd"],"properties":{"amount_usd":{"type":"number","minimum":0.01,"multipleOf":0.01,"description":"Minimum 0.01, and AT MOST 2 decimal places — a card holds whole cents. A finer amount is rejected with 400 rather than silently rounded, so your ledger and ours can never differ by the difference. No per-call maximum — bounded only by your available_usd (a larger amount is refused with 402, not capped)."}}},"example":{"amount_usd":200}}}},"responses":{"200":{"description":"Card credited.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"card_id":{"type":"string"},"amount_requested_usd":{"type":"number"},"fee_usd":{"type":"number","description":"Always 0 on the float model — the load fee is taken when you fund."},"credited_usd":{"type":"number"},"partner_balance_usd":{"type":"number"}}}}},"example":{"data":{"card_id":"7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u","amount_requested_usd":200,"fee_usd":0,"credited_usd":200,"partner_balance_usd":9800}}}}},"400":{"description":"Body not a JSON object, amount_usd not a number or <= 0, or the card is closed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"description":"Balance or limit will not cover it. The message names which one you hit.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"That Idempotency-Key has already been used.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"$ref":"#/components/responses/RateLimited"},"502":{"description":"The card processor could not be reached or errored. Retry shortly — 5xx means OUR side, never yours.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Upstream authentication problem on our side — retry shortly. Code UPSTREAM_AUTH.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/cards/{cardId}/unload":{"parameters":[{"name":"cardId","in":"path","required":true,"schema":{"type":"string"}}],"post":{"tags":["Cards"],"operationId":"unloadCard","x-scope":"cards:write","summary":"Move money off a card back into your balance","description":"The inverse of topup: takes `amount_usd` off the card and returns it to your balance, 1:1 with no fee, without closing the card. ALWAYS send an `Idempotency-Key` — a retry without one can subtract twice. Asking for more than the card holds is refused with 402 and nothing moves.","parameters":[{"$ref":"#/components/parameters/IdempotencyKey"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["amount_usd"],"properties":{"amount_usd":{"type":"number","minimum":0.01,"multipleOf":0.01,"description":"At most 2 decimal places — a card holds whole cents."}}},"example":{"amount_usd":50}}}},"responses":{"200":{"description":"Money moved back to your balance","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"card_id":{"type":"string"},"unloaded_usd":{"type":"number"},"partner_balance_usd":{"type":"number"}}}}},"example":{"data":{"card_id":"7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u","unloaded_usd":50,"partner_balance_usd":9995}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/cards/{cardId}/limits":{"parameters":[{"name":"cardId","in":"path","required":true,"schema":{"type":"string"}}],"get":{"tags":["Cards"],"operationId":"getCardLimits","x-scope":"cards:read","summary":"Read the card spending limits","description":"The card's configured daily, weekly and monthly spending limits. null means no limit is set and only the program-level $5,000 per-transaction ceiling applies — that ceiling is not settable per card.","responses":{"200":{"description":"Current limits","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"card_id":{"type":"string"},"daily_limit":{"type":["number","null"]},"weekly_limit":{"type":["number","null"]},"monthly_limit":{"type":["number","null"]},"per_transaction_limit_usd":{"type":"number"}}}}},"example":{"data":{"card_id":"7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u","daily_limit":500,"weekly_limit":null,"monthly_limit":4000,"per_transaction_limit_usd":5000}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"502":{"description":"The card processor could not be reached or errored. Retry shortly — 5xx means OUR side, never yours.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Upstream authentication problem on our side — retry shortly. Code UPSTREAM_AUTH.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"put":{"tags":["Cards"],"operationId":"setCardLimits","x-scope":"cards:write","summary":"Set the card spending limits","description":"Any of daily_limit, weekly_limit, monthly_limit — each a positive USD amount. Fields you omit are left as they are; removing a limit is not yet supported. The response reads the limits back from the processor, so it reflects what actually took effect.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"daily_limit":{"type":"number","minimum":0.01},"weekly_limit":{"type":"number","minimum":0.01},"monthly_limit":{"type":"number","minimum":0.01}}},"example":{"daily_limit":500,"monthly_limit":4000}}}},"responses":{"200":{"description":"Limits as they now stand, or { card_id, updated: true } when the change took effect but the read-back to the processor failed","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"card_id":{"type":"string"},"daily_limit":{"type":["number","null"]},"weekly_limit":{"type":["number","null"]},"monthly_limit":{"type":["number","null"]},"per_transaction_limit_usd":{"type":"number"},"updated":{"type":"boolean","description":"Present only on the fallback body: true when the limits were set but reading them back from the processor failed. The change still took effect."}}}}},"example":{"data":{"card_id":"7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u","daily_limit":500,"weekly_limit":null,"monthly_limit":4000,"per_transaction_limit_usd":5000}}}}},"400":{"description":"No settable limit field present, or a limit that is not a positive number.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"502":{"description":"The card processor could not be reached or errored. Retry shortly — 5xx means OUR side, never yours.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Upstream authentication problem on our side — retry shortly. Code UPSTREAM_AUTH.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/events":{"get":{"tags":["Webhooks"],"operationId":"listEvents","x-scope":"cards:read","summary":"Every webhook event we generated for you","description":"Newest first, delivered or not — the backfill for a consumer that was down. Filter with ?type= and ?since= (ISO 8601); page with ?limit= (max 100, default 50) and ?page= (1-based). Payloads are exactly what the webhook delivery carried, so one code path can process both.","parameters":[{"name":"type","in":"query","schema":{"type":"string"}},{"name":"since","in":"query","schema":{"type":"string","format":"date-time"}},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":50}},{"name":"page","in":"query","schema":{"type":"integer","minimum":1,"default":1}}],"responses":{"200":{"description":"Event history","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"events":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"type":{"type":"string"},"payload":{"type":"object"},"delivered":{"type":"boolean"},"delivered_at":{"type":["string","null"]},"attempts":{"type":"integer"},"created_at":{"type":"string"}}}},"count":{"type":"integer"},"page":{"type":"integer"},"total":{"type":"integer"},"total_pages":{"type":"integer"}}}}},"example":{"data":{"events":[{"id":"e1f4…","type":"card.transaction","payload":{},"delivered":true,"delivered_at":"2026-08-22T10:00:05Z","attempts":1,"created_at":"2026-08-22T10:00:00Z"}],"count":1,"page":1,"total":84,"total_pages":2}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/events/{eventId}/redeliver":{"parameters":[{"name":"eventId","in":"path","required":true,"schema":{"type":"string"}}],"post":{"tags":["Webhooks"],"operationId":"redeliverEvent","x-scope":"users:write","summary":"Deliver one past event again","description":"Queues the event for delivery with a fresh retry schedule. It arrives with the SAME event id, so deduplicating on id — as the webhook guide instructs — makes redelivery safe at any time.","responses":{"200":{"description":"Queued","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"event_id":{"type":"string"},"type":{"type":"string"},"queued":{"type":"boolean"}}}}},"example":{"data":{"event_id":"e1f4…","type":"card.transaction","queued":true}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/cards/balances":{"post":{"tags":["Cards"],"operationId":"getBulkCardBalances","x-scope":"cards:read","summary":"Balances at fleet scale","description":"Served from our balance read model, so it answers instantly for ANY number of cards: omit card_ids to page the whole fleet (limit up to 1000), or send up to 1000 ids. Every row carries as_of — when that balance was last read from the processor; the model is written through on every money movement and refreshed continuously, stalest first. refresh:true forces live reads and is capped at 25 ids, one upstream call each.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"card_ids":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":1000,"description":"Distinct card ids. Omit for the whole fleet, paginated. At most 25 when refresh is true."},"refresh":{"type":"boolean","default":false,"description":"Live processor reads. Requires card_ids, at most 25."},"limit":{"type":"integer","minimum":1,"maximum":1000,"default":500,"description":"Fleet mode page size."},"page":{"type":"integer","minimum":1,"default":1,"description":"Fleet mode page."}}},"example":{"limit":500,"page":1}}}},"responses":{"200":{"description":"One outcome per requested card, in request order","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"balances":{"type":"array","items":{"type":"object","properties":{"card_id":{"type":"string"},"ok":{"type":"boolean","description":"false when no dated balance reading exists yet (a card the sweep has not reached) or the row could not be served. Inspect error to tell them apart: error null with ok false means not-yet-read."},"balance_usd":{"type":["number","null"],"description":"null only when no reading exists yet or a live refresh failed. A drained card reads 0, not null."},"as_of":{"type":["string","null"],"description":"When this balance was last read from the processor. null = not yet swept."},"error":{"type":["string","null"],"description":"NOT_FOUND (not your card) or UNAVAILABLE (a failed live refresh) when ok is false"}}}},"count":{"type":"integer","description":"Rows in this response."},"page":{"type":"integer","description":"Fleet mode only (card_ids omitted): the page returned."},"total":{"type":"integer","description":"Fleet mode only: cards across all pages."},"total_pages":{"type":"integer","description":"Fleet mode only: total pages for the fleet."}}}}},"example":{"data":{"balances":[{"card_id":"7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u","ok":true,"balance_usd":240.5,"as_of":"2026-08-24T10:12:00.000Z","error":null}],"count":1,"page":1,"total":4000,"total_pages":8}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/cards/{cardId}/pan-token":{"parameters":[{"name":"cardId","in":"path","required":true,"schema":{"type":"string"}}],"post":{"tags":["Cards"],"operationId":"mintCardDataToken","x-scope":"cards:pan","summary":"Card details for native display — no iframe required","description":"Mints a SINGLE-USE URL valid for 60 seconds. Hand it to the END USER'S DEVICE and have the device GET it directly: the response is `{ data: { pan, cvv, expiry_month, expiry_year } }`, uncacheable, and the token burns on first use. Calling it from your own servers instead puts the card number through your infrastructure and changes your PCI DSS obligations — route it through the device. Mint one per display, not per session. Prefer GET /cards/{cardId}/pan (the embeddable frame) where an iframe is possible.","responses":{"200":{"description":"A single-use exchange URL","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"url":{"type":"string","description":"Have the device GET this within 60 seconds."},"expires_in":{"type":"integer"},"single_use":{"type":"boolean"}}}}},"example":{"data":{"url":"https://virtual.ktap.io/card-data/eyJ…","expires_in":60,"single_use":true}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/cards/{cardId}/phone":{"parameters":[{"name":"cardId","in":"path","required":true,"schema":{"type":"string"}}],"put":{"tags":["Cards"],"operationId":"updateCardPhone","x-scope":"cards:write","summary":"Change where 3-D Secure codes are sent","description":"Updates the mobile number this card sends 3-D Secure one-time codes to. Cardholders change numbers; without this the only remedy would be closing the card and issuing a new one. Applies to cards in `sms` 3DS mode. The number is not echoed back: it is cardholder data you just sent.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["phone_country_code","phone"],"properties":{"phone_country_code":{"type":"integer","minimum":1,"description":"Dialling code without the +."},"phone":{"type":"string","pattern":"^[0-9]{4,15}$","description":"Digits only."}}},"example":{"phone_country_code":1,"phone":"5551234567"}}}},"responses":{"200":{"description":"The number was updated","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"card_id":{"type":"string"},"updated":{"type":"boolean"}}}}},"example":{"data":{"card_id":"7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u","updated":true}}}}},"400":{"description":"INVALID_PHONE: a malformed number or country code, or one the card processor refuses as invalid. PHONE_COUNTRY_NOT_ALLOWED: numbers with that country code are not accepted. BAD_REQUEST: a malformed body, or any other refusal. The number was not changed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"502":{"description":"The card processor could not be reached or errored. Retry shortly — 5xx means OUR side, never yours.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Upstream authentication problem on our side — retry shortly. Code UPSTREAM_AUTH.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/cards/{cardId}/freeze":{"parameters":[{"name":"cardId","in":"path","required":true,"schema":{"type":"string"}}],"post":{"tags":["Cards"],"operationId":"setCardFrozen","x-scope":"cards:write","summary":"Freeze or unfreeze","description":"Blocks or restores spending immediately. Reversible, and the balance is untouched.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["frozen"],"properties":{"frozen":{"type":"boolean"}}},"example":{"frozen":true}}}},"responses":{"200":{"description":"New state.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"card_id":{"type":"string"},"frozen":{"type":"boolean"}}}}},"example":{"data":{"card_id":"7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u","frozen":true}}}}},"400":{"description":"Body not a JSON object, frozen not a boolean, or the card is closed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"502":{"description":"The card processor could not be reached or errored. Retry shortly — 5xx means OUR side, never yours.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Upstream authentication problem on our side — retry shortly. Code UPSTREAM_AUTH.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/cards/{cardId}/close":{"parameters":[{"name":"cardId","in":"path","required":true,"schema":{"type":"string"}}],"post":{"tags":["Cards"],"operationId":"closeCard","x-scope":"cards:write","summary":"Close a card and return its balance","description":"Ends the card permanently and sweeps any remaining balance back to your account. Unlike freeze, this is not reversible — and it stops the monthly card fee, which is charged for every card that exists whether or not it is used.\n\nThe balance is read and returned BEFORE the card is deleted. If we cannot read it we do not close the card at all and return 503, because closing it would strand the money.\n\nIDEMPOTENT PER CARD: retrying a completed close returns the ORIGINAL result (same returned_usd), not an error — no Idempotency-Key needed; the card id is the key. In the rare case a previous attempt failed mid-sweep, further attempts return 409 ALREADY_PROCESSED while we reconcile it manually — contact support if that state persists beyond an hour.","security":[{"ApiKeyAuth":["cards:write"]}],"responses":{"200":{"description":"Closed.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"card_id":{"type":"string"},"returned_usd":{"type":"number","description":"Balance swept back to your float."},"partner_balance_usd":{"type":"number"}}}}},"example":{"data":{"card_id":"7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u","returned_usd":43.2,"partner_balance_usd":9988.2}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"A previous close of THIS card failed mid-sweep and is being reconciled by hand — the balance is safe, do not retry; contact support if it persists. (A retry of a SUCCESSFULLY closed card is NOT a 409 — it returns 200 with the original returned_usd.)","content":{"application/json":{"example":{"error":"A previous close attempt did not complete cleanly and is being reconciled — contact support before retrying","code":"ALREADY_PROCESSED"}}}},"429":{"$ref":"#/components/responses/RateLimited"},"502":{"description":"The card processor could not be reached or errored. Retry shortly — 5xx means OUR side, never yours.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Balance unreadable — the card was NOT closed. Safe to retry.","content":{"application/json":{"example":{"error":"Could not read the card balance — try again shortly","code":"UNAVAILABLE"}}}}}}},"/cards/{cardId}/pan":{"parameters":[{"name":"cardId","in":"path","required":true,"schema":{"type":"string"}}],"get":{"tags":["Cards"],"operationId":"getCardDetails","x-scope":"cards:pan","summary":"Card number, expiry and CVV","description":"Returns a short-lived URL rendering the full card details, for you to show the cardholder in an\niframe or webview. The number itself never passes through KTAP or through you, which keeps it out\nof both systems.\n\nRequires the `cards:pan` scope, separate from `cards:read` on purpose — a key that lists cards and\nreads balances has no need to see card numbers. Responses are sent no-store; do not cache or\npersist anything from this endpoint.\n\nEmbedding: `details_url` is served from the SAME HOST as this API, allows framing from any origin,\nand works in an iframe or webview — your `frame-src` allowlist needs only our hostname, which you\nalready trust. Each call to this endpoint mints a fresh URL that expires after about ten minutes;\ntreat it as a secret (whoever has it can view the card), never log it, and request a new one each\ntime the cardholder asks rather than storing it.\n\nThe page has tap-to-copy built in, so the cardholder can copy the number without it ever entering\nyour code — but browsers block the clipboard inside a cross-origin iframe unless you allow it:\ngive your iframe `allow=\"clipboard-write\"`. When the cardholder copies, the page posts\n`{ type: 'ktap-card-copy-result', ok: boolean }` to your window, so your UI can show its own\nconfirmation without ever touching the number.","responses":{"200":{"description":"Details URL and expiry.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"card_id":{"type":"string"},"details_url":{"type":"string","description":"Short-lived URL to render in an iframe. Do not cache."},"expiration_month":{"type":["integer","null"],"description":"NULL if the processor could not be reached for card metadata."},"expiration_year":{"type":["integer","null"]}}}}},"example":{"data":{"card_id":"7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u","details_url":"https://virtual.ktap.io/card-frame/dG9rZW4…","expiration_month":10,"expiration_year":2032}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"502":{"description":"The card processor could not be reached or errored. Retry shortly — 5xx means OUR side, never yours.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/cards/{cardId}/transactions":{"parameters":[{"name":"cardId","in":"path","required":true,"schema":{"type":"string"}}],"get":{"tags":["Cards"],"operationId":"listCardTransactions","x-scope":"cards:read","summary":"Card activity","description":"Card activity, most recent first (ordering follows the processor default, so do not depend on it for correctness; use from_date/to_date windows). `amount_usd` is the purchase amount including the card\nprocessor's fees, to the cent; `provider_fee_usd` is how much of that was fee and `fee_breakdown` itemizes\nit (fx / atm / deposit). `fee_usd` adds your markup, `client_fee_usd`, which is not inside `amount_usd`.\n\nEach purchase appears once, however many stages it has been through. A recent purchase appears as\n`pending` until the merchant settles it. Amounts and fees are reported to the cent.\n\nPagination: `limit` (1-25, default 25 — a larger value is REFUSED with 400, never clamped), `page` (1-based), and `from_date`/`to_date` (YYYY-MM-DD)\nfor history windows. Page with `page` until you reach `total_pages` (a short page is NOT the end signal).\nFor reconciliation,\nwalk date windows rather than relying on \"newest first\" alone.","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":25,"default":25},"description":"Max 25 — each row is fetched in detail. Page through for more."},{"name":"page","in":"query","schema":{"type":"integer","minimum":1,"default":1}},{"name":"from_date","in":"query","schema":{"type":"string","format":"date"}},{"name":"to_date","in":"query","schema":{"type":"string","format":"date"}}],"responses":{"200":{"description":"Transactions.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"card_id":{"type":"string"},"transactions":{"type":"array","items":{"$ref":"#/components/schemas/Transaction"}},"count":{"type":"integer","description":"Rows in this page."},"page":{"type":"integer","description":"The page returned (1-based)."},"total_pages":{"type":"integer","description":"Total pages for this filter."},"total":{"type":"integer","description":"Total operations across all pages."}}}}},"example":{"data":{"card_id":"7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u","transactions":[{"id":"d4f7b0c3","transaction_id":"tid_01a05d12-6e7f-7a8b-9c0d-1e2f3a4b5c6d","datetime":"2026-09-16T14:16:21","status":"settled","merchant":"CORNER CAFE","amount_usd":25.5,"original_amount":25,"original_currency":"USD","exchange_rate":1.02,"fee_usd":0.5,"fee_breakdown":{"fx_usd":0.5,"atm_usd":0,"deposit_usd":0},"decline_reason":null}],"count":1,"page":1,"total_pages":1,"total":1}}}}},"400":{"description":"limit/page not a positive integer, or from_date/to_date malformed or reversed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"502":{"description":"The card processor could not be reached. History is UNAVAILABLE, not empty — retry shortly rather than recording zero activity.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"Upstream authentication problem on our side — retry shortly. Code UPSTREAM_AUTH.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/deposits":{"get":{"tags":["Deposits"],"operationId":"listDeposits","x-scope":"deposits:read","summary":"List deposits","description":"Deposits you have raised, newest first, with their status.","responses":{"200":{"description":"Your deposits.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"deposits":{"type":"array","items":{"$ref":"#/components/schemas/DepositRecord"}},"count":{"type":"integer"}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"}}},"post":{"tags":["Deposits"],"operationId":"createDeposit","x-scope":"deposits:write","summary":"Get a deposit address for a card","description":"Returns the card's own deposit address and the amount to ask for. When the transfer confirms the card\nis credited and you get a `deposit.credited` webhook. No float needed.\n\nOn EVM and Bitcoin the address belongs to that card alone, so a round amount is fine. Solana has no\nper-card address, so there the amount is what identifies the payment — check `exact_amount_required`\nand only insist on the exact figure when it is true.\n\nThis call moves NO money — it only reserves a quote — so it needs no `Idempotency-Key` (send one and it\nis ignored). It is safe to retry, but each call opens a NEW pending quote with its own `deposit_id`, and\non a shared-address chain (Solana) a new, different `send_amount`. So call it once per user request,\nstore the `deposit_id`, and reuse that quote rather than re-requesting.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["card_id","asset","amount_usd"],"properties":{"card_id":{"type":"string"},"asset":{"type":"string","enum":["BTC","ETH","BNB","SOL","USDT-ERC20","USDC-ERC20","USDT-BEP20","USDC-BEP20","USDT-POLYGON","USDC-POLYGON","USDT-TRC20"],"description":"Which coin and network the user will send. A code we recognise but that is not currently enabled returns 503 — treat the enum as the full menu and 503 as \"not right now\".","example":"USDC-BEP20"},"amount_usd":{"type":"number","minimum":0.01,"description":"Roughly what they want on the card, before fees. Must clear the fees or the call is refused — a few dollars is the practical floor. No per-call maximum."}}},"example":{"card_id":"7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u","asset":"USDC-BEP20","amount_usd":100}}}},"responses":{"201":{"description":"Send these details to your user.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"deposit":{"$ref":"#/components/schemas/DepositQuote"}}}}}}}},"400":{"description":"Unknown asset, amount_usd not positive or too small to cover fees, or a malformed body.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"$ref":"#/components/responses/RateLimited"},"502":{"description":"The card processor could not be reached or errored. Retry shortly — 5xx means OUR side, never yours.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"That asset is not available right now.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/ledger":{"get":{"tags":["Account"],"operationId":"listLedger","x-scope":"users:read","summary":"Every entry that moved your balance","description":"The itemisation behind `float.spent_usd` on GET /me: load fees, card top-ups, the monthly card fee per card, and balances swept back by closing a card. Positive `amount_usd` adds to your balance, negative draws it down. `reference` ties an entry back to its cause, with a prefix per kind: `topup:<your-Idempotency-Key>` for a top-up, `cardfee:<cardId>:<YYYY-MM-01>` for a monthly card fee (only fees settled from your float appear here; invoice-billed fees are not in the ledger), `close:<cardId>` for a balance swept back by closing a card, `unload:<key>` (with `unload:<key>:intent` alongside it) for money moved off a card, and `funding-<NETWORK>-<tx_hash>` for a transaction credited through POST /funding (its load fee is the same reference with `:fee` appended). A reversal of such a funding is a negative funding row referenced `funding-reversal-<NETWORK>-<tx_hash>` with a positive fee row, the same reference with `:fee` appended, returning the load fee. Newest first; page with `page` until `total_pages`.","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":50}},{"name":"page","in":"query","schema":{"type":"integer","minimum":1,"default":1}}],"responses":{"200":{"description":"Ledger entries, newest first.","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Requests allowed per minute."},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Left in the current minute."}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"entries":{"type":"array","items":{"type":"object","properties":{"amount_usd":{"type":"number"},"kind":{"type":"string","enum":["funding","fee","card_topup","refund","adjustment"]},"reference":{"type":["string","null"]},"card_id":{"type":["string","null"]},"description":{"type":["string","null"]},"created_at":{"type":"string","format":"date-time"}}}},"count":{"type":"integer"},"page":{"type":"integer"},"total":{"type":"integer"},"total_pages":{"type":"integer"}}}}},"example":{"data":{"entries":[{"amount_usd":-0.5,"kind":"fee","reference":"cardfee:7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u:2026-08-01","card_id":"7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u","description":"Monthly card fee","created_at":"2026-08-01T00:05:00.000Z"},{"amount_usd":-200,"kind":"card_topup","reference":"topup:order-8842","card_id":"7Kd2mQ4rTz9XbW1sYh6vN0pLcE3fJa8u","description":"Top-up card ••••7640","created_at":"2026-08-01T09:00:00.000Z"}],"count":2,"page":1,"total":2,"total_pages":1}}}}},"400":{"description":"limit/page not a positive integer, or limit above 100.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/funding/addresses":{"get":{"tags":["Account"],"operationId":"listFundingAddresses","x-scope":"users:read","summary":"Get your funding addresses","description":"The funding address for each network you can fund your balance on, with the tokens it accepts there,\nrefreshed at least every 10 minutes. Only networks whose transaction hashes POST /funding can verify and\ncredit are listed, and only addresses that are currently enabled: send only to a network and token\nlisted here, to the address listed for that network, from a wallet you have registered with us. Send at\nleast 10 USD per transfer: a smaller transfer is never picked up, so it cannot be credited.\n\nRead it before you send rather than storing it. When the addresses cannot be read you get 503\n`UNAVAILABLE`, never a guessed address: wait and retry, and do not send until it answers. It also answers\n503 when only the BNB Chain and Base addresses cannot be read; a Polygon submission to POST /funding\nkeeps working then.","responses":{"200":{"description":"The funding addresses, at most one per network, in the order POLYGON, BNB, BASE.","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Requests allowed per minute."},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Left in the current minute."}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"addresses":{"type":"array","items":{"type":"object","required":["network","address","tokens"],"properties":{"network":{"type":"string","enum":["POLYGON","BNB","BASE"],"description":"Name this network when you submit the hash to POST /funding."},"address":{"type":"string","pattern":"^0x[0-9a-fA-F]{40}$","description":"Where to send on this network. Compare addresses case-insensitively."},"tokens":{"type":"array","items":{"type":"string","enum":["USDC","USDT"]},"description":"The stablecoins this address accepts on this network. Send nothing else."}}}}}}}},"example":{"data":{"addresses":[{"network":"POLYGON","address":"0x5c1E8A3D7f24B690e1a4C8d3F7B25e906A1D4c8E","tokens":["USDC","USDT"]},{"network":"BNB","address":"0x5c1E8A3D7f24B690e1a4C8d3F7B25e906A1D4c8E","tokens":["USDC","USDT"]},{"network":"BASE","address":"0x5c1E8A3D7f24B690e1a4C8d3F7B25e906A1D4c8E","tokens":["USDC","USDT"]}]}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"},"503":{"description":"UNAVAILABLE: the funding addresses, or only the BNB Chain and Base ones, could not be read just now. No address is returned; retry shortly.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/funding":{"post":{"tags":["Account"],"operationId":"submitFunding","x-scope":"users:write","summary":"Fund your balance with a transaction hash","description":"Send USDC or USDT to the funding address GET /funding/addresses returns for the network you send on, then\nsubmit the transaction hash here. We verify it on-chain immediately, then credit your balance once the\nfunds arrive: the amount sent on-chain, minus the load fee on your agreement. On Polygon this is usually\nwithin minutes. On BNB Chain and Base the funds are routed first and we credit only once your transaction\nis final on its network, so it can take several hours. Follow it with GET /funding/{fundingId}, or listen\nfor the `float.credited` webhook.\n\nBefore your first transfer, tell us through your contact or the support channel which wallet or wallets\nyou send from. We register them to your account; the same address counts on every network, and a wallet\ncan belong to one account only, so never name an exchange's hot wallet or a wallet that relays or solves\nbridge transfers. Our own wallets and card deposit addresses cannot be registered. A transfer is credited\nautomatically only when exactly one wallet paid into your funding address in that transaction, that\nwallet is registered to your account, and it paid directly: the wallet itself sent the transaction straight\nto the token contract, or it is a smart wallet, such as a multisig, executing its own transfer. A gasless,\nbundled (ERC-4337) or relayed transfer goes to review. Registration is not visible through the API.\n\nWhose money it is is decided first, then the amount. A transaction one of our own wallets paid into goes to\n`review` with reason `operator_sender`, and one more than one wallet paid into with `multiple_senders`.\nOtherwise, when the only wallet that paid is registered to another account, it is `rejected` with reason\n`unregistered_sender`: it is that account's money. One from a wallet registered to nobody goes to `review`\nwith reason `unregistered_sender`, and one the wallet did not pay directly (gasless, bundled, relayed, or\nrouted through a router, batcher, bridge or solver) with `routed_transfer`. A transaction that delivered bridged\nfunds into the funding address, on any network, is rejected with reason `bridge_fill`: it is the arrival\nof a deposit made elsewhere, so the depositor claims their own transaction on the network they sent from.\nIf you bridged the funds yourself, contact us with both hashes.\n\nAccepted: USDC and USDT on Polygon, BNB Chain and Base, as GET /funding/addresses lists them, at least 10\nUSD per transfer. Always your funding address, never a card address: a transaction that sent nothing to\nyour funding address is rejected with reason `not_a_funding_transfer`, one that sent it a token not\naccepted on that network with reason `unsupported_token`, and one under 10 USD with reason\n`below_minimum`. A network GET /funding/addresses does not list answers `400`, and nothing is stored.\n\nNo `Idempotency-Key` is needed: this call is idempotent on the transaction hash, so a retry is always\nsafe. Submitting the same hash with the same network returns the same record. Named the wrong network?\nWhile your submission is still unverified (`pending` with a null `amount_usd`, or `rejected` with reason\n`not_found`), submitting the same hash with a different network looks the same submission, same `id`,\nup on that network; resubmitting a `not_found` hash with the same network looks it up again. The 24\nhours run from your first submission. Once the transaction is verified its network is fixed, and\nresubmitting returns the same record whatever network you name. Each transaction can be credited once.\n\nA hash belongs to the account whose registered wallet sent the money, when it is the only wallet that paid\nand it was registered before any other account claimed the hash:\nits submission replaces any other\naccount's claim on the hash that has not been paid, whether `pending`, in `review` or `rejected`,\nincluding one we rejected. Failing that, the first account to submit it on the right network\nkeeps it, so submit as soon as the transaction is broadcast; another account's claim that named another\nnetwork and never verified there is replaced by one that verifies. A replaced claim disappears from\nGET /funding and GET /funding/{fundingId} answers 404 for it. A paid claim is never displaced. When another\naccount's claim on the hash could still give way to yours (it is still being looked up, or is verified on\nyour network but not paid) and yours cannot be read on its network yet, you get\n`503 UNAVAILABLE`: retry shortly. Otherwise you get `409 ALREADY_SUBMITTED`, and retrying does not help:\nif the transaction is yours, check the network and that your sending wallet is registered, then contact us.\n\nA 200 means the hash was recorded, not that it was credited: read `status`. It can already be `credited`,\n`rejected` when the transaction fails verification, or `review` when it verified but cannot be credited\nautomatically. At most 5 of your submissions can be unverified at once, even under concurrent requests:\npast that, a new submission gets `429 TOO_MANY_PENDING` until some are found or rejected as `not_found`.\nIt carries no `Retry-After`. A correction of your own submission is still accepted.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["tx_hash","network"],"properties":{"tx_hash":{"type":"string","pattern":"^0x[0-9a-fA-F]{64}$","description":"0x followed by 64 hex characters, in any case. Stored lowercase."},"network":{"type":"string","enum":["POLYGON","BNB","BASE"],"description":"The network the transaction was made on. BSC is accepted as another name for BNB."}}},"example":{"tx_hash":"0x4a7e2c9d1b8f3e6a5c0d7b2e9f4a1c8d3e6b0f5a2d9c7e4b1a8f6d3c0e5b2a91","network":"POLYGON"}}}},"responses":{"200":{"description":"Recorded. A repeat of the same hash returns the same record; one that corrects the network of an unverified submission returns it looked up on that network, with the same id.","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Requests allowed per minute."},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Left in the current minute."}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Funding"}}},"example":{"data":{"id":"3b9e6f1a-7c42-4d85-a1e0-5f2c8d9b4e63","network":"POLYGON","tx_hash":"0x4a7e2c9d1b8f3e6a5c0d7b2e9f4a1c8d3e6b0f5a2d9c7e4b1a8f6d3c0e5b2a91","status":"pending","reason":null,"token":"USDC","amount_usd":2500.25,"fee_usd":null,"credited_usd":null,"created_at":"2026-09-23T10:15:02.114Z","credited_at":null}}}}},"400":{"description":"Body not valid JSON or not an object, tx_hash not 0x plus 64 hex characters, network not POLYGON, BNB (or BSC) or BASE, or a network GET /funding/addresses does not list at the moment. Nothing was recorded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"409":{"description":"ALREADY_SUBMITTED: another account holds this hash and keeps it: its claim has been paid, it submitted the hash first on the same network and the money did not come from a wallet registered to you alone, or its claim names another network and your transaction cannot be credited on yours. Retrying does not help: if the transaction is yours, check the network and that your sending wallet is registered, then contact us. ALREADY_CREDITED: this transaction was already credited to a balance by other means, and is not retryable.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"RATE_LIMITED: too many requests this minute; wait for Retry-After. TOO_MANY_PENDING: 5 of your submissions are still not found on their network. It carries no Retry-After: wait until some are found, or rejected as not_found 24 hours after their first submission, before submitting more. A correction of your own submission is still accepted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"An internal fault. Retry with the same hash shortly. A network that is slow or cannot be read leaves the submission pending instead.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"UNAVAILABLE: the funding addresses could not be read just now, so the hash could not be checked. When only the BNB Chain and Base addresses cannot be read, only a submission on those networks gets this; Polygon keeps working. Also when another account's claim on the hash could still give way to yours (it is still being looked up, or is verified on your network but not paid) and your submission cannot be read on its network yet, so it could not be verified to replace that claim. Nothing was recorded; retry with the same hash shortly.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"get":{"tags":["Account"],"operationId":"listFundings","x-scope":"users:read","summary":"List your funding submissions","description":"Your 50 most recent submissions to POST /funding, newest first, with their status. There is no paging. A submission replaced by another account's claim, because that account's registered wallet sent the money or its claim verified on the right network, no longer appears.","responses":{"200":{"description":"Your submissions.","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Requests allowed per minute."},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Left in the current minute."}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"fundings":{"type":"array","items":{"$ref":"#/components/schemas/Funding"}},"count":{"type":"integer","description":"Rows returned, at most 50."}}}}},"example":{"data":{"fundings":[{"id":"3b9e6f1a-7c42-4d85-a1e0-5f2c8d9b4e63","network":"POLYGON","tx_hash":"0x4a7e2c9d1b8f3e6a5c0d7b2e9f4a1c8d3e6b0f5a2d9c7e4b1a8f6d3c0e5b2a91","status":"credited","reason":null,"token":"USDC","amount_usd":2500.25,"fee_usd":25.0025,"credited_usd":2475.2475,"created_at":"2026-09-23T10:15:02.114Z","credited_at":"2026-09-23T10:17:40.502Z"},{"id":"8d4a2c7e-1f93-4b6d-9e05-c3a7b1f8d240","network":"BNB","tx_hash":"0x1f8c3e5a7d2b9046c1e8a3f5d7b2c9e4a6f1d8b3c5e7a2f9d4b6c1e8a3f50246","status":"rejected","reason":"not_a_funding_transfer","token":null,"amount_usd":null,"fee_usd":null,"credited_usd":null,"created_at":"2026-09-22T16:40:11.308Z","credited_at":null}],"count":2}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/funding/{fundingId}":{"parameters":[{"name":"fundingId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"get":{"tags":["Account"],"operationId":"getFunding","x-scope":"users:read","summary":"Get one funding submission","description":"Where one submission stands. Poll it after POST /funding until `status` is `credited` or `rejected`, or listen for the `float.credited` webhook instead. A submission in `review` is resolved by us and then becomes one of those two. A credited submission that was not your money can later be reversed by us, and then reads `rejected` with reason `reversed_by_operator`.","responses":{"200":{"description":"The submission.","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"},"description":"Requests allowed per minute."},"X-RateLimit-Remaining":{"schema":{"type":"integer"},"description":"Left in the current minute."}},"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/Funding"}}},"example":{"data":{"id":"3b9e6f1a-7c42-4d85-a1e0-5f2c8d9b4e63","network":"POLYGON","tx_hash":"0x4a7e2c9d1b8f3e6a5c0d7b2e9f4a1c8d3e6b0f5a2d9c7e4b1a8f6d3c0e5b2a91","status":"credited","reason":null,"token":"USDC","amount_usd":2500.25,"fee_usd":25.0025,"credited_usd":2475.2475,"created_at":"2026-09-23T10:15:02.114Z","credited_at":"2026-09-23T10:17:40.502Z"}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"No submission with that id under your account, or it was replaced by another account's claim: that account's registered wallet sent the money, or its claim verified on the right network. Code NOT_FOUND.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"$ref":"#/components/responses/RateLimited"}}}},"/webhook":{"get":{"tags":["Webhooks"],"operationId":"getWebhook","x-scope":"users:read","summary":"Get your callback URL","description":"The secret is not returned here. To get a NEW secret, PUT /webhook with {\"rotate\": true} — re-PUTting the same URL alone deliberately keeps your existing secret.","responses":{"200":{"description":"Current setting. webhook is null before you have ever registered one.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"webhook":{"type":["object","null"],"description":"Null until you register a URL.","properties":{"url":{"type":"string"},"active":{"type":"boolean"},"updated_at":{"type":"string","format":"date-time"}}}}}}},"example":{"data":{"webhook":{"url":"https://yoursite.com/hooks/ktap","active":true,"updated_at":"2026-08-12T10:37:58.160Z"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"}}},"put":{"tags":["Webhooks"],"operationId":"setWebhook","x-scope":"users:write","summary":"Set your callback URL","description":"Point us at an HTTPS endpoint and we will POST events to it. Deliveries originate from a single\nstatic IP, 204.168.213.162 — allowlist it if your endpoint filters inbound traffic, and verify the\nsignature on every delivery regardless; we will notify you ahead of any change to the address.\nIf you are unreachable we retry 8 times\nwith doubling backoff (1, 2, 4, 8, 16, 32, 64 minutes) — roughly 2 hours from the first attempt — then\nabandon automatic retries. After that you can requeue it yourself with POST /events/{eventId}/redeliver, or backfill from GET /events; GET /cards/{cardId}/transactions also reconciles settled card activity.\n\nThe response contains a signing secret, shown once.\n\n**Every delivery** is an HTTPS POST with body `{ \"id\": \"…\", \"type\": \"card.declined\", \"created_at\":\n\"…ISO 8601…\", \"data\": { …event fields… } }` and four headers: `X-KTAP-Signature` — hex HMAC-SHA256,\nkeyed with your secret, over the exact string `\"{timestamp}.{raw request body}\"`; `X-KTAP-Timestamp`\n— the unix timestamp in SECONDS used in that signature; `X-KTAP-Event` — the event type, same as\n`type` in the body; `X-KTAP-Delivery` — a unique id for this event, STABLE across retries — dedupe on\nit, because retries mean you can receive the same event twice.\n\nVerify by recomputing the HMAC over `X-KTAP-Timestamp + \".\" + raw body` and comparing to\n`X-KTAP-Signature` (constant-time compare). Use the RAW body bytes: re-serialising the JSON can\nreorder keys and break the comparison. Reject stale timestamps (older than ~5 minutes) to block\nreplays.\n\n**Respond 2xx within 8 seconds** — anything else counts as a failed attempt. We retry up to 8 times\nwith doubling backoff starting at 1 minute (roughly 2 hours of retries), then the event is abandoned.\nProcess asynchronously if your handler is slow: ack first, work after.\n\nEvents: `card.issued`, `card.topped_up`, `card.closed`, `card.authorized`, `card.transaction`, `card.declined`,\n`deposit.credited`, `float.low`, `float.credited`. Ignore any event type you do not recognise.\n\nEvent payloads (`data`):\n`card.issued` — { card_id, partner_ref, last4, card_holder_name }.\n`card.topped_up` — { card_id, partner_ref, amount_requested_usd, fee_usd, credited_usd, partner_balance_usd }.\n`card.transaction` / `card.declined` — { card_id, partner_ref, transaction_id, event, status, merchant,\nmcc_code, mcc_category, merchant_country, amount_usd, original_amount, original_currency, exchange_rate,\nfee_usd, fee_breakdown: { fx_usd, atm_usd }, decline_reason, occurred_at }. `transaction_id`\nmatches `transaction_id` (not `id`) on GET /cards/{id}/transactions rows — use it to reconcile webhooks against pulled history.\nUse `mcc_code`/`mcc_category` to recognise high-risk merchant categories per purchase.\n`occurred_at` is UTC with a Z suffix (e.g. 2026-09-16T14:16:21.204Z). NOTE it differs from\nthe transactions endpoint `datetime`, which is also UTC but carries NO suffix — append Z when parsing it. The envelope `created_at` is\nwhen WE recorded the event and is stable across redeliveries.\n`card.authorized` has the same fields. Its `amount_usd` is the total taken from the card, fee included,\nand its `fee_usd` is the purchase's fee. A `cleared` `card.transaction` does not repeat that fee: its\n`fee_usd` is normally 0, so keep the authorization's figures while `original_amount` is unchanged; if it changed,\ntreat the charge as open and reconcile it with us by `transaction_id`.\n`card.closed` — carries `returned_usd`, the balance that came back to your account.\n`float.low` — carries `available_usd` (the same figure GET /me reports), `balance_usd` and\n`threshold_usd` — queue new top-ups when it arrives rather than waiting for a 402.\n`float.credited`: { funding_id, network, tx_hash, token, amount_usd, fee_usd, credited_usd, balance_usd }.\nSent once, when a transaction hash you submitted to POST /funding is credited to your balance, including\none we credit after review. `funding_id` is the `id` POST /funding returned, and `balance_usd` is your\nbalance after the credit. `amount_usd` is the amount credited before the load fee: normally the amount\nsent; after a review, if less reached us than was sent, no more than reached us. GET /funding/{fundingId}\nalways reports the amount sent as its `amount_usd`, with the same `fee_usd` and `credited_usd`. Nothing\nis sent for a submission credited separately or for a reversal.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["url"],"properties":{"url":{"type":"string","format":"uri","description":"Must be https."},"rotate":{"type":"boolean","description":"Force a NEW secret even when the URL is unchanged. Without it, re-PUTting the same URL is idempotent and keeps your existing secret — safe for deploy scripts."}}},"example":{"url":"https://yoursite.com/hooks/ktap"}}}},"responses":{"200":{"description":"Saved. Store the secret now.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"webhook":{"type":"object","properties":{"url":{"type":"string"}}},"secret":{"type":["string","null"],"description":"Shown once when created or rotated. NULL when the URL was unchanged — your existing secret still applies. Send rotate:true to force a new one."},"note":{"type":"string"}}}}},"example":{"data":{"webhook":{"url":"https://yoursite.com/hooks/ktap"},"secret":"whsec_…","note":"Store this secret now — it is not shown again."}}}}},"400":{"description":"URL was missing, malformed, or not https.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"429":{"$ref":"#/components/responses/RateLimited"}}}}},"x-scopes":["users:read","users:write","cards:read","cards:write","cards:pan","deposits:read","deposits:write"]}