Skip to content

Endpoints

5. Endpoints

5.1 POST /paymentapi/v1/deposits

Creates a deposit; routed to a payment account automatically.

Two modes, one endpoint. Sending redirect: true gives 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 when redirect is 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):

{ "success": false, "message": "Bu tutar icin uygun hesap yok" }

Duplicate reference (HTTP 200 or 409):

{ "success": false, "message": "Bu islem numarasi zaten kullanilmis" }

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 on success, 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, _, ., -

iban is 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 as pending.

Success (200):

{ "success": true, "message": "Çekim talebiniz alındı. Birazdan işleme alınacaktır" }

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

data is 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_id is masked (1324***10). amount is a JSON number with 2 decimals. Withdrawals additionally include iban.

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:

  1. You send redirect: true and omit amount — the player enters it on the page.
  2. The response carries a payment_url; redirect the player there.
  3. The player enters the amount on that page. At that point the same CreateDeposit used by the direct flow (5.1) runs, and the page shows the IBAN.
  4. 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:

{ "success": false, "message": "Validation failed: Amount is required when Redirect is false" }
{ "success": false, "message": "Validation failed: Amount must not be sent when Redirect is true" }

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 false covers both "you sent redirect: false" and "you did not send redirect at all", since the field defaults to false. Sending amount as 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, the strings.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 unchangedmodel/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_url comes 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 the CUZDAN360_* config system — it isn't read from config.yaml, so check with your Cuzdan360 operator that it's set before relying on payment_url being 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_url calls 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.