Endpoints¶
5. Endpoints¶
5.1 POST /paymentapi/v1/deposits¶
Creates a deposit; routed to a payment account automatically.
Two modes, one endpoint. Sending
redirect: truegives you the hosted payment page — the recommended integration, documented in 5.5. Everything in this section describes the direct mode, which is what you get whenredirectis omitted: you receive the IBAN and render it yourself.If you are integrating for the first time, read 5.5 first — the field table below still applies to both modes, except for
amount.
Request:
{
"amount": "1000",
"bank_code": "1",
"national_id": "12345678901",
"first_name": "Test",
"last_name": "User",
"username": "testuser",
"user_id": "user001",
"reference": "TX-DEP-001",
"method": "fast"
}
| Field | Type | Required | Constraint |
|---|---|---|---|
amount |
string | Yes, unless redirect: true |
Parses to a number, > 0. Must be omitted in hosted mode — see 5.5 |
bank_code |
string | Yes | 1–20 chars (payment channel / bank id) |
national_id |
string | No | National ID — optional, no format check |
first_name |
string | Yes | 2–50 chars; letters (Unicode), spaces, -, ', . |
last_name |
string | Yes | 2–50 chars; same character set |
middle_name |
string | No | Optional middle name: up to 50 chars after trimming; letters (Unicode), spaces, -, ', ., at least one letter. An invalid value never rejects the request (the placeholder strings null, undefined, none and yok, in any letter case, count as invalid; JSON null, an empty or whitespace-only string count as not sent): it is ignored and the request is processed without a middle name. When set, it is stored with the transaction and the payer/payee name used on the payment side becomes First Middle Last. Callbacks are unchanged: full_name stays first + last name; the middle name is delivered only as the optional middle_name extra callback field, if enabled for your account (see the Callbacks section). |
username |
string | Yes | 3–100 chars; letters (Unicode), digits and symbols such as @ + _ . - # (e-mail and phone style usernames are accepted); NOT allowed: whitespace, control or invisible characters, and < > " ' ` \ |
user_id |
string | Yes | 1–50 chars |
reference |
string | Yes | 1–100 chars, unique (idempotency key) — your own transaction number |
method |
string | No | fast, eft, or havale (empty → fast) |
Success (200):
{
"success": true,
"message": "Deposit received",
"assigned_bank_name": "Ziraat Bankasi",
"assigned_holder_name": "Test Holder",
"assigned_iban": "TR330006100519786457841326"
}
Show these account details to the user; payment goes to this account. The assigned_* fields are omitted entirely when the request is rejected.
Client timeout for deposits: when the payment channel first chosen for a deposit is unavailable, the request is served by another account, but only after that channel's timeout. A successful response can then take up to about 20 seconds. Use an HTTP client timeout of at least 30 seconds for
POST /paymentapi/v1/deposits. If your request still times out, do not retry with a newreference: queryGET /paymentapi/v1/transactions/by-reference/{reference}(5.4) first.200means the deposit exists; show itsassigned_*account.404means no transaction was created, so you can retry with the samereference. Retrying with the samereferenceis always safe: if the first request did create the deposit, the retry is rejected withBu islem numarasi zaten kullanilmisand no second deposit is opened.
No account available (HTTP 422, or 200 in legacy mode):
Duplicate reference (HTTP 200 or 409):
Business rejections — the body is always { "success": false, "message": … }; only the HTTP code varies:
| HTTP | message |
|---|---|
| 409 | Halihazirda bekleyen talebiniz var |
| 409 | Bu islem numarasi zaten kullanilmis |
| 422 | Bu tutar icin uygun hesap yok |
| 422 | Ödeme kanalı talebi kabul etmedi |
| 403 | Yatırım şu anda kapalı |
| 403 | Kullanıcı yasaklı |
| 403 | Yatırım tutarı limit dışında |
Ödeme kanalı talebi kabul etmedi: the payment channel chosen for this deposit declined it and it could not be served by another account (for example a player-specific decline). No transaction is created, so the samereferencecan be retried later. Channel-wide failures (timeouts, outages) are normally served by another account and return a normal success response.If strict HTTP statuses are disabled for your account (legacy mode), all of these come back as HTTP 200 with
success: false. Always branch onsuccess, not on the HTTP code alone.
5.2 POST /paymentapi/v1/withdrawals¶
Creates a withdrawal.
Request:
{
"bank_code": "1",
"amount": "5000",
"first_name": "Test",
"last_name": "User",
"national_id": "12345678901",
"iban": "TR330006100519786457841326",
"reference": "TX-WDR-001",
"user_id": "user001",
"username": "testuser"
}
| Field | Type | Required | Constraint |
|---|---|---|---|
bank_code |
string | Yes | 1–20 chars |
amount |
string | Yes | Parses to a number, > 0 |
first_name |
string | Yes | 2–50 chars; letters (Unicode), spaces, -, ', . |
last_name |
string | Yes | 2–50 chars; same character set |
middle_name |
string | No | Optional middle name: up to 50 chars after trimming; letters (Unicode), spaces, -, ', ., at least one letter. An invalid value never rejects the request (the placeholder strings null, undefined, none and yok, in any letter case, count as invalid; JSON null, an empty or whitespace-only string count as not sent): it is ignored and the request is processed without a middle name. When set, it is stored with the transaction and the payer/payee name used on the payment side becomes First Middle Last. Callbacks are unchanged: full_name stays first + last name; the middle name is delivered only as the optional middle_name extra callback field, if enabled for your account (see the Callbacks section). |
national_id |
string | No | Optional, no format check |
iban |
string | No | Max 64 chars — deliberately loose, see note |
reference |
string | Yes | 1–100 chars, unique — your own transaction number |
user_id |
string | Yes | 1–50 chars |
username |
string | Yes | 3–100 chars; letters (Unicode), digits and symbols such as @ + _ . - # (e-mail and phone style usernames are accepted); NOT allowed: whitespace, control or invisible characters, and < > " ' ` \ |
ibanis validated loosely on purpose. A withdrawal with a missing or malformed IBAN is still accepted so your site can always submit the request; the only constraint is a 64-character ceiling. Whether the IBAN is actually valid is surfaced to the operator in the panel as a valid/invalid badge under the IBAN. An invalid IBAN therefore does not produce a 400 — the transaction is created aspending.
Success (200):
The withdrawal response carries only success and message — no assigned_* fields.
Business rejections: Halihazirda bekleyen talebiniz var (409), Bu islem numarasi zaten kullanilmis (409), Çekim şu anda kapalı (403), Kullanıcı yasaklı (403) — same { "success": false, "message": … } body.
5.3 GET /paymentapi/v1/transactions/{id}¶
Query status by the cuzdan360 transaction number (the internal integer id). You only see your own transactions.
Response (200):
{
"success": true,
"status": "pending",
"data": {
"id": 1,
"reference": "TX-DEP-001",
"type": "deposit",
"status": "pending",
"amount": 1000.00,
"method": "fast",
"first_name": "Test",
"last_name": "User",
"username": "testuser",
"user_id": "user001",
"national_id": "1234***01",
"bank_code": "1",
"assigned_bank_name": "Ziraat Bankasi",
"assigned_holder_name": "Test Holder",
"assigned_iban": "TR330006100519786457841326",
"created_at": "2026-03-30T12:00:00Z",
"updated_at": "2026-03-30T12:00:00Z"
}
}
| Field | Meaning |
|---|---|
success |
Whether the query succeeded (boolean). Not the transaction state |
status |
The transaction state (string) — the same value as data.status, duplicated at the top level so you can read it without descending into data |
data |
Brand-scoped slim view of the transaction |
datais a brand-scoped slim view. Internal fields are not returned (firm_id,merchant_id,callback_url,processed_by,handling_by,amount_edited_by,original_amount,decision_note*,refund_*).national_idis masked (1324***10).amountis a JSON number with 2 decimals. Withdrawals additionally includeiban.middle_nameis included only when you sent a valid one (an ignored value is not stored); otherwise the key is omitted and the response is unchanged.
Errors — a different, two-key envelope:
| HTTP | Body |
|---|---|
| 400 | { "success": false, "message": "invalid id" } — {id} is not an integer |
| 404 | { "success": false, "message": "transaction not found" } — unknown, or belongs to another account |
5.4 GET /paymentapi/v1/transactions/by-reference/{reference}¶
Query by the reference you submitted. Response format is identical to 5.3.
These endpoints are for status checks / reconciliation — they do not replace the callback. The authoritative result is delivered by callback.
5.5 Hosted Payment Page (redirect: true)¶
This is the integration to build unless you have a reason not to. You hand the payment screen to Cuzdan360: no IBAN rendering, no amount handling, and the auto-cancel countdown the player sees is maintained by us.
Same endpoint (POST /paymentapi/v1/deposits), same headers, same auth/callback/SSRF checks from section 4 — only the request body and success response differ.
Flow:
- You send
redirect: trueand omitamount— the player enters it on the page. - The response carries a
payment_url; redirect the player there. - The player enters the amount on that page. At that point the same
CreateDepositused by the direct flow (5.1) runs, and the page shows the IBAN. - The result reaches you by callback, exactly like the direct flow. You don't poll the page.
Request:
{
"redirect": true,
"bank_code": "1",
"national_id": "12345678901",
"first_name": "Test",
"last_name": "User",
"username": "testuser",
"user_id": "user001",
"reference": "TX-DEP-002",
"method": "fast",
"return_url": "https://yoursite.com/wallet"
}
bank_code, first_name, last_name, username, user_id, reference, national_id, method follow the exact same constraints as 5.1 and are stored as-is (service/payment_session.go: CreatePaymentSession) — they're replayed unchanged into CreateDeposit once the player submits an amount. middle_name (optional) is resolved when the session is created (trimmed; an invalid value is ignored) and the resolved value is carried to the transaction when the player submits the amount.
The amount / redirect rule (model/hosted_flow_test.go: TestRedirectDrivesAmountRule):
| Case | Result |
|---|---|
redirect absent/false + amount empty |
400 |
redirect: true + amount non-empty |
400 |
redirect: true + amount empty/absent |
OK |
redirect absent/false + amount present and valid |
OK |
The two rejections are distinguishable — the message tells you which side of the rule you violated:
The message names the Go struct field (
Amount,Redirect), not the JSON key (amount,redirect) — that is the existing convention across every validation message in this API, not something specific to this rule.Redirect is falsecovers both "you sentredirect: false" and "you did not sendredirectat all", since the field defaults tofalse. Sendingamountas an empty string has the same effect as omitting it.
return_url (optional):
Only processed when redirect: true (model/payment_session.go: ValidateReturnURL, called from service/payment_session.go: CreatePaymentSession). In the direct flow it's accepted by the schema (subject to the length check below) but never validated or used further — silently ignored.
Past the struct-level omitempty,max=500 check (over 500 chars → Validation failed: ReturnURL is too long), hosted-mode requests additionally validate the URL shape:
| Value | Result |
|---|---|
| Empty / omitted | OK — page shows no return button |
Absolute http:// or https:// URL, no userinfo |
OK |
| Unparseable | 400 return_url ayrıştırılamadı |
Scheme other than http/https (e.g. ftp://, javascript:) |
400 return_url yalnız http veya https olabilir |
No host (e.g. https:///path) |
400 return_url bir sunucu adı içermeli |
Contains userinfo (e.g. https://user@evil.com) |
400 return_url kullanıcı bilgisi (userinfo) içeremez |
These four messages are in Turkish and come back without the
Validation failed:prefix — they're produced by the service layer (CreatePaymentSession), not the struct validator, and the handler passes them through verbatim (handler/handler.go, thestrings.HasPrefix(serr.Error(), "return_url")branch).
If accepted, return_url makes the page show a button back to your site; if omitted, the page is terminal (no return option). This is a click, not an automatic redirect — the page doesn't navigate away on its own, since the player may still need it open while copying the IBAN into their banking app.
Success (200):
{
"success": true,
"message": "Redirect required",
"payment_url": "https://pay.360cuzdan.com/t/Qr6Of0qwvzNNKMgM2YvvvrvKYtUNUtqBtQC2wzp9l1w"
}
payment_url is omitempty and appears only in this mode; assigned_* fields are absent from this response (no account is assigned yet). The direct-flow response is unchanged — model/hosted_flow_test.go: TestPaymentURLIsOmittedWhenEmpty locks both directions: no payment_url key when it's empty, present when it's not.
Operational note —
PAYMENT_PAGE_BASE_URL: if this environment variable isn't set on the server,payment_urlcomes back as a relative path (/t/<token>) instead of an absolute URL (handler/handler.go: paymentPageURL) — a deliberate fail-visible choice over guessing a wrong domain. It's a plain process environment variable, not part of theCUZDAN360_*config system — it isn't read fromconfig.yaml, so check with your Cuzdan360 operator that it's set before relying onpayment_urlbeing directly usable.
Errors specific to this mode (the general auth / callback-URL / SSRF / 500 errors from section 9 still apply — this table only covers what's new):
| HTTP | message |
|---|---|
| 400 | Validation failed: Amount is required when Redirect is false — direct flow, amount missing |
| 400 | Validation failed: Amount must not be sent when Redirect is true — hosted flow, amount sent anyway |
| 400 | Validation failed: ReturnURL is too long — over 500 chars |
| 400 | return_url ayrıştırılamadı / return_url yalnız http veya https olabilir / return_url bir sunucu adı içermeli / return_url kullanıcı bilgisi (userinfo) içeremez — malformed return_url |
| 500 | An internal error occurred. Please try again later. — session couldn't be persisted |
There is no business-rule rejection at this step. redirect: true never calls CreateDeposit — it only opens a session (service/payment_session.go: CreatePaymentSession). Checks like "pending request exists", "user banned", "amount out of limits", or "no suitable account" all run later, when the player submits the amount on the page. If rejected there, it's shown only on the page — the player can retry with a different amount, and you receive nothing (no transaction was created, so no callback fires). You only get a callback once a real (pending) transaction is created and later approved or rejected — same as the direct flow.
Session lifetime: 30 minutes (service/payment_session.go: paymentSessionTTL). This TTL only kills a link nobody used. Once the player submits an amount and the session becomes assigned, the page keeps showing the IBAN — a bank transfer can take minutes, and the page must not disappear on them (model/payment_session.go: PaymentSession.Status).
The link is not permanent, though: a consumed session stays readable only while the transaction it opened is still pending. The moment that transaction reaches a terminal state — approved, rejected (including auto-cancel), or any other non-pending status — both GET /api/pay/:token and POST /api/pay/:token/deposit return expired, and the IBAN, amount and account-holder name are no longer served (service/payment_session.go: paymentSessionLinkDead). The ceiling is therefore however long the transaction can stay pending: the provider's auto_cancel_minutes when auto-cancel is on (7 days at most), or a panel decision when it is 0.
Callback URL: resolved once, at redirect: true request time, using the same priority order as the direct flow (provider > X-Callback-URL header > firm webhook — section 4), then stored on the session. It is not re-resolved when the player later submits the amount.
reference: the value you send in the redirect: true request becomes the reference of the transaction opened once the player submits an amount (service/payment_session.go: SubmitPaymentSessionAmount). You can query it afterward via GET /paymentapi/v1/transactions/by-reference/{reference} (5.4) — until the player submits, no transaction exists yet, so the query returns 404 transaction not found.
Page-internal endpoints: the page at
payment_urlcalls its own endpoints (GET /api/pay/:token,POST /api/pay/:token/deposit) to load the session and submit the amount. These are not part of this integration API — they carry no HMAC signature and are protected by the token alone. Don't call them from your backend.