Three calls mint a payment link a Coincashy guest can open to buy crypto with fiat. This page walks the calls in order, then documents every field and everything that comes back. Every path below is called against trade.coincashy.io.
JWT authentication, guest profile provisioning, webhook events, and hosted fiat-to-crypto checkout links.
Automated wallet payouts, institutional RFQ quotes, and custom multi-currency ledger routing.
System architecture flow
Steps 1-3 are yours to call from the Coincashy backend. Everything from "guest opens link" onward is the widget's job — it's covered in §06 so you know what to expect on the other end, not because you need to build it. Once the fiat lands, paying the crypto out is a separate call you make yourself — §07.
One header. Send it on every call and you're covered.
| HEADER | VALUE | APPLIES TO |
|---|---|---|
Authorization | Bearer <token> from login | Every /api/v3/* call — §03 and §04. You won't have one yet when calling /jwt/generate itself. |
Server-to-server — no password, no OTP screen. api_key / api_secret are the integration credentials the platform team issues to Coincashy; username is the merchant account they're tied to.
/api/jwt/generate{
"api_key": "<coincashy api key>",
"api_secret": "<coincashy api secret>",
"username": "ops@coincashy.io"
}{
"iat": 1758100000,
"exp": 1758103600,
"type": "Bearer",
"token": "eyJhbGciOiJIUzI1NiIs...",
"scopes": [ "..." ],
"api_data": {
"user": { "user_type": "customer" }
}
}This one call lives at /api/jwt/generate — not under /api/v3/ like everything else in this guide. From here on, use token exactly the same way: Authorization: Bearer <token> on every call in §03 and §04.
The token is good for 1 hour, fixed. There's no separate refresh call — minting a new token immediately invalidates whichever one was last issued to that username, so just call /jwt/generate again when you need a fresh one (don't run two of these at once for the same username).
A wrong api_key / api_secret / username combination comes back as a plain 401 with the text Invalid Credentials — not the JSON error body used elsewhere in this guide.
The guest is the person who'll open the link and buy crypto. You have two options, depending on whether you want the link to auto-log them in.
Register the guest's email against your merchant account before minting the link. Pass this guest's email as payload.username in step 4 and the widget skips straight to the purchase — no OTP screen.
/api/v3/auth/create-guest-with-pii{
"email": "demo.guest@coincashy.io"
}{
"message": "Guest user created/updated successfully",
"userId": "10432",
"isNew": true
}auth.controller.ts currently declares two handlers on the identical route POST /auth/create-guest-with-pii — one bare-bones (just writes a PII blob), one full-featured (also accepts KYC files/questionnaire answers, links the guest to your merchant account, and kicks off Sumsub onboarding). Express/Nest only ever runs the first-declared handler for a duplicated route — the second is dead code. So the response you actually get, and whether the guest ends up linked to your merchant, may not match what the fuller description promises. Worth one live test against staging before depending on it.
Omit username entirely in step 4. The link still works — opening it returns login_required, and the widget itself calls the guest self-onboarding routes (/auth/guest/register then /auth/guest/login with an emailed OTP). Simplest option if you don't already know the guest's email ahead of time, or don't need an auto-login link.
This is the endpoint you started with — corrected. The body needs a payload wrapper; a bare { flow, username } object at the top level fails validation.
/api/v3/payments/guests/onramp{
"payload": {
"flow": "onramp",
"username": "demo.guest@coincashy.io",
"user_id": "<coincashy merchant id>",
"fiat_currency": "EUR",
"receive_currency": "USDT",
"network": "TRON"
}
}{
"paymentLink": "https://trade.coincashy.io/widget/token?paymentId=pl%3A6f9c9a2e-3b7a-4e2b-9a1e-2c9b6a5b9f10"
}The link's host isn't a request field — it's read from the target merchant's WIDGET_URI setting server-side (falling back to a platform-wide default if unset). If the returned link doesn't point at trade.coincashy.io, that's a setting to fix on the merchant record you're logged in as, not a payload option.
The token after pl: is an opaque, server-minted secret — not an encrypted id you decode. It's the credential: whoever presents it gets the guest session it's bound to, so treat the whole link like a bearer token in transit (send over HTTPS, don't log it in plaintext, don't put it somewhere it'll be cached or indexed).
Everything payload and the optional notification object accept.
| Field | Type | Notes |
|---|---|---|
| flow | string (req) | always "onramp" for Coincashy |
| user_id | string (req) | the Coincashy merchant id — scopes the link to your merchant account. The caller must be an operator authorized to target it. |
| username | an existing guest's email → auto-login link. Absent → self-service link. | |
| external_id | string | your idempotency / reference key. Must be unique. Server generates a UUID if omitted. |
| fiat_currency | string | one of USD, EUR, AED |
| fiat_amount | num str | positive, up to 2 decimal places, e.g. "250.00" |
| receive_currency | string | one of USDC, USDT, BTC, TRX — the crypto the guest ends up with |
| network | string | one of ETHEREUM, BITCOIN, TRON, BINANCE_SMART_CHAIN |
| vendor_id | string | pre-selects a PSP provider — skips the picker screen |
| method | string | pre-selects a payment method, e.g. "Wire Transfer" |
| Field | Type | Notes |
|---|---|---|
| webhook_url | URL | server-to-server callback once the payment settles |
| success_url | URL | where the widget redirects the guest on success |
| fail_url | URL | where the widget redirects the guest on failure |
Only a non-guest caller can hit this endpoint — a guest JWT gets a flat 403. That's an identity check on you (the caller), separate from the guest the link is being created for.
In plain terms:
username in step 04, they're logged straight in. Otherwise they get a one-time code by email first.notification.webhook_url gets called, and you can poll for the status yourself any time.notification.webhook_url fires once the payment reaches a final state. If you'd rather check directly — or the webhook never arrives — poll this endpoint with the external_id you set in step 04.
/api/v3/psp/onramp?fB=cid&fV=3fa1c2e0-...-uuid&fT=S&fO=EQfB/fV/fT/fO = filter field / value / type / operator · S = string, EQ = equals
{
"totalItems": 1,
"result": [
{
"id": "58213",
"status": "PENDING_PAYMENT",
"cid": "3fa1c2e0-...-uuid",
"external_payment_id": "psp-ref-88213",
"merchant_id": "77",
"created_at": "2026-09-17T16:02:11.000Z",
"updated_at": "2026-09-17T16:04:40.000Z"
}
]
}| status | Means |
|---|---|
| PENDING_PAYMENT | still in progress — guest is somewhere in checkout |
| PENDING_SIGNATURE, PENDING_TRANSFER | still in progress |
| COMPLETED | done — the guest has their crypto |
| REJECTED / REJECTED_BY_ADMIN | failed / closed out |
| expired / EXPIERD | guest never finished it in time |
| FULL_REFUND / CHARGEBACKED | money went back — treat as failed |
This row only appears once the guest actually reaches the PSP checkout step inside the widget — not the moment you create the link. An empty result right after sending the link usually just means the guest hasn't gotten that far yet.
One more thing worth knowing: the link's expiry mirrors the access-token lifetime, so it dies on the same clock as the JWT it hands out — ask the platform team for the current JWT_ACCESS_EXPIRE_IN value rather than assuming one. Past that, opening the link fails with "payment link expired".
Once the fiat deposit is approved (§06), the Coincashy merchant's crypto balance updates. From there, the merchant can withdraw that balance to an external wallet — the usual three things: wallet address, currency, and network.
This pays out of Coincashy's own account balance on the platform — not an amount escrowed per deposit. A confirmed fiat deposit credits that balance; the merchant then chooses to withdraw from it. Keep it funded, or the call fails with INSUFFICIENT_FUNDS.
Public, no auth needed. The same currency can appear several times — once per network it's issued on — each with its own id. Match the network the destination wallet is actually on.
/api/currencies{
"api_data": {
"currencies": [
{ "id": "bkE0RmNjbEhCUmc9", "name": "USD", "network": "FIAT", "type": "FIAT" },
{ "id": "eGFCV203K3VSOGM9", "name": "BTC", "network": "BITCOIN", "type": "CRYPTO" },
{ "id": "ckk3THQ2KzVmd009", "name": "USDC", "network": "BINANCE_SMART_CHAIN", "type": "CRYPTO" },
{ "id": "aFpaK2ViQlVob0U9", "name": "USDC", "network": "TRON", "type": "CRYPTO" },
{ "id": "OTJqTmNBRk1LMVU9", "name": "USDC", "network": "ETHEREUM", "type": "CRYPTO" }
]
}
}Three rows above are all named "USDC" with three different ids — one per network. Sending the wrong one doesn't fail loudly; it just tries to pay out on the wrong chain. Always pick by network, never by name alone.
| Field | Type | Notes |
|---|---|---|
| amount | num str (req) | plain decimal in the currency's normal units, e.g. "1" = 1 USDC — not base units |
| recipient_address | string (req) | the destination wallet — validated against the address format for the resolved network |
| currency_id | string (rec) | the id from the currency list above — pins the exact network |
| currency | string (fallback) | a bare name like "USDC" — only use without currency_id; falls back to a platform default network |
| initial_rate | num str (opt) | the exchange rate to lock in for this payout |
| initial_rate_currency_id | string (opt) | which currency initial_rate is priced against |
/api/transfers{
"amount": "1",
"currency": "USDC",
"currency_id": "ckk3THQ2KzVmd009",
"recipient_address": "0xA10fC656A5c470A5A45b5a468E774E46FC039Ffb",
"initial_rate": "0.99985735",
"initial_rate_currency_id": "bkE0RmNjbEhCUmc9"
}{
"api_data": {
"transfer": {
"id": "94217",
"status": "PENDING",
"amount": "1",
"currency": "USDC",
"recipient_address": "0xA10fC656A5c470A5A45b5a468E774E46FC039Ffb"
}
}
}Poll by the id the create call returned. A webhook also fires on status changes — ask the platform team how Coincashy's is configured, since (unlike the onramp link) this call has no per-request webhook_url field of its own.
/api/transfers/94217{
"api_data": {
"transfer": {
"id": "94217",
"status": "CONFIRMED"
}
}
}For §04's onramp-link call.
| Code | Meaning here | Typical cause |
|---|---|---|
| 201 | Link created | — |
| 400 | Bad request | unknown fiat_currency / receive_currency / network, a reused external_id, a username that isn't a guest, or missing payload/flow |
| 401 | Unauthorized | missing or expired bearer token |
| 403 | Forbidden | caller is a guest, or a non-operator caller passed someone else's user_id |
| 500 | Server error | unhandled — worth reporting with the request id / timestamp |
{
"statusCode": 400,
"timestamp": "2026-09-17T10:04:12.441Z",
"path": "/api/v3/payments/guests/onramp",
"method": "POST",
"error": "Bad Request",
"message": "invalid EURX fiat_currency"
}Everything on this page is read straight from the current source. These points are flagged because the code disagrees with itself, or because the value isn't in the code at all — confirm them once against staging, then this list is done.
api_key / api_secret from the platform team — they don't come back from any endpoint./jwt/generate token is actually accepted as Authorization: Bearer on /api/v3/* calls — it's issued by a different, older auth system than the rest of the API.fB/fV/fT/fO filter query on GET /psp/onramp (§06) still resolves by cid the way it's documented here./auth/create-guest-with-pii live (§03) — confirm which of the two duplicate handlers actually answers, and whether the guest ends up linked to your merchant.WIDGET_URI is set to trade.coincashy.io, so links resolve to the right domain.JWT_ACCESS_EXPIRE_IN value from the platform team — it's the actual onramp-link TTL./transfers pays out of that balance and returns INSUFFICIENT_FUNDS when it's short.