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 |
username |
string | Yes | 3–30 chars; letters (Unicode), digits, _, ., - |
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.
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 |
| 403 | Yatırım şu anda kapalı |
| 403 | Kullanıcı yasaklı |
| 403 | Yatırım tutarı limit dışında |
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 |
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–30 chars; letters (Unicode), digits, _, ., - |
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.
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.
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.