WinPay Integration DocsTürkçe →

WinPay API — Integration Guide

This guide is for developers connecting the WinPay payment method to their own site. It covers only the API contract: three endpoints, request signing, the result callback, statuses and errors. Every HTTP transcript below was captured from a running server, not hand-written.

Version: v1 · Base URL: https://api.win-pay.co · Türkçe sürüm


1. Overview

WinPay is a payment method: it collects and pays out on your behalf and reports the result to you with a signed notification (callback). Your member's balance is managed by you.

What Method & path
Create deposit POST /api/v1/deposits/init
Create withdrawal POST /api/v1/withdrawals/init
Result callback POST to your HTTPS URL

Deposit: your site creates a request → redirects the member to the payment_url from the response → the member picks the amount on the payment page and transfers by IBAN or QR → once the payment is verified you receive a callback → your site credits the balance.

Withdrawal: your site creates a request (with the member's IBAN) → the transfer is made → you receive a callback → your site marks the withdrawal as paid.

Important: a 201 response means "request created", not "money moved". Change the balance only when the callback arrives.

There is no status-polling endpoint. Results arrive by callback. If you think a callback was lost, re-send the same request_id — you get the existing transaction back (section 8).


2. Credentials

We give you Purpose
Base URL https://api.win-pay.co
api_key identifies your site — sent as X-API-Key
api_secret signs your requests
callback_secret verifies our callbacks (a different value)
You give us Note
Callback URL HTTPS only, no redirects. e.g. https://api.your-site.example/payments/callback
Outbound IP(s) added to the allow-list; list all of them

Security rules


3. Authentication and signing

Every request passes three layers:

Layer How
Site identity X-API-Key header
IP check your outbound IP must be on the allow-list
Request signature X-WinPay-Signature — HMAC-SHA256

How the signature is computed

signed_string = <unix_seconds> + "." + <raw request body bytes>
signature     = HMAC_SHA256(signed_string, api_secret)   ->  64 lowercase hex

Headers on every call:

X-API-Key:           <api_key>
X-WinPay-Timestamp:  1789041098
X-WinPay-Signature:  e55ffec5...4fe
Content-Type:        application/json

The window is ±300 seconds. If your clock drifts more than 5 minutes, requests are rejected.

⚠️ The most common mistake: serialize the JSON body once and use that same string for signing and sending. Re-serializing a parsed object can reorder keys and break the signature. The signature is verified over the bytes you actually sent.


4. Deposit

4.1 Request

POST /api/v1/deposits/init
Field Type Required Rule
request_id string 1–100 chars, A-Z a-z 0-9 . _ : -. Unique per request
member_id string non-empty, max 100. Send numeric IDs as strings
member_name string max 150
amount string positive, dot decimal, max 2 decimals — "1000.00"
is_fast boolean FAST transfer request

Not accepted as amount: "1.000,00", -100, 0, "100.001", 1e3, "abc"400.

You do not choose the bank account or IBAN — the system allocates one that fits the amount. Your site is identified by the API key, never by the body.

4.2 Response fields (201)

Field Type Note
transaction_id integer WinPay's id — keep it
payment_url string send the member here (top-level redirect, not an iframe)
amount string the amount as accepted
status string always pending_payment at creation
expires_at string ISO-8601 UTC, 20 minutes ahead
idempotent boolean present only on a repeat of the same request_id

4.3 Transcript — create

POST /api/v1/deposits/init HTTP/1.1
Host: api.win-pay.co
Content-Type: application/json
X-API-Key: wp_test_key_0001
X-WinPay-Timestamp: 1789041098
X-WinPay-Signature: e55ffec5d4ea23e78d86048bbd2d778349dd69efa0ea8c5961857d4ee1bba4fe

{"request_id":"dep-20260910-000001","member_id":"member-42","member_name":"Ali Veli","amount":"1000.00","is_fast":false}
HTTP/1.1 201 Created
Content-Type: application/json

{
  "success": true,
  "data": {
    "transaction_id": 1,
    "payment_url": "https://api.win-pay.co/pay/106a96a6776569e77675a1e4e95eae4b6e61ae71b34fe186bb403b2b28d89a10",
    "amount": "1000.00",
    "status": "pending_payment",
    "expires_at": "2026-09-10T12:11:38.860Z"
  }
}

4.4 Redirect the member

Lifetime: 20 minutes. A request not approved within that time becomes expired.

The amount can change. The member may pick a different amount on the payment page. Credit the member with approved_amount from the callback — never the amount you sent.

4.5 Transcript — same request_id, identical body

Still 201, same transaction_id, idempotent: true inside data, no expires_at:

HTTP/1.1 201 Created
Content-Type: application/json

{
  "success": true,
  "data": {
    "transaction_id": 1,
    "payment_url": "https://api.win-pay.co/pay/106a96a6776569e77675a1e4e95eae4b6e61ae71b34fe186bb403b2b28d89a10",
    "amount": "1000.00",
    "status": "pending_payment",
    "idempotent": true
  }
}

After a timeout, retry with the same request_id — it acts as a lookup and never creates a second transaction.

4.6 Transcript — same request_id, different body

POST /api/v1/deposits/init HTTP/1.1
...
{"request_id":"dep-20260910-000001","member_id":"member-42","member_name":"Ali Veli","amount":"2000.00","is_fast":false}
HTTP/1.1 409 Conflict
Content-Type: application/json

{
  "success": false,
  "code": "CONFLICT",
  "message": "Aynı referans farklı işlem verisiyle kullanılamaz"
}

Look up the existing transaction. Do not retry under a new reference.


5. Withdrawal

POST /api/v1/withdrawals/init

Same as deposit, minus is_fast, plus two required fields:

Field Type Required Rule
request_id string as above
member_id string as above
member_name string max 150
amount string as above
iban string TR IBAN, 26 chars, mod-97 checked
account_name string non-empty, max 150

Response fields (201):

Field Type Note
transaction_id integer WinPay's id
status string always pending_assignment at creation
amount string the amount as accepted
idempotent boolean only on a repeat

There is no payment_url for withdrawals. You learn the outcome from the callback.

Transcript

POST /api/v1/withdrawals/init HTTP/1.1
Host: api.win-pay.co
Content-Type: application/json
X-API-Key: wp_test_key_0001
X-WinPay-Timestamp: 1789041098
X-WinPay-Signature: 103f8be05c8164d09c2dcfb62f94b8b3073df93ea9902d24b71115786a93222d

{"request_id":"wd-20260910-000001","member_id":"member-42","member_name":"Ali Veli","amount":"500.00","iban":"TR330006100519786457841326","account_name":"ALI VELI"}
HTTP/1.1 201 Created
Content-Type: application/json

{
  "success": true,
  "data": {
    "transaction_id": 2,
    "status": "pending_assignment",
    "amount": "500.00"
  }
}

201 means accepted, not paid. IBAN validation proves the format is valid, not who owns the account. Verifying that the account belongs to the member is your side's responsibility — so is reserving or refunding the member's balance.


6. Result callback

This is the most critical part of the integration. Money becomes final here.

6.1 Payload

Identical shape for deposits and withdrawals:

POST https://api.your-site.example/payments/callback
Content-Type: application/json
X-WinPay-Signature: <64 hex HMAC-SHA256>
X-WinPay-Event-Id: winpay:1
{
  "event_id": "winpay:1",
  "event": "transaction.approved",
  "transaction_id": 1,
  "request_id": "dep-20260910-000001",
  "type": "deposit",
  "amount": "1000.00",
  "approved_amount": "950.00",
  "status": "approved",
  "member_id": "member-42",
  "timestamp": "2026-09-10T11:51:38.941Z"
}
Field Type Note
event_id string winpay:<n>your deduplication key
event string transaction.approved / .rejected / .completed / .expired
transaction_id integer the id from the create response
request_id string the request_id you sent
type string deposit or withdrawal
amount string the originally requested amount
approved_amount string the amount that actually moved — use this one
status string see the table in 6.3
member_id string your member id, as you sent it
timestamp string ISO-8601 UTC, when the notification was built

Headers we send:

Header Value
Content-Type application/json
X-WinPay-Signature HMAC_SHA256(raw body, callback_secret) — 64 hex
X-WinPay-Event-Id same as body event_id

The callback signature has no timestamp prefix (unlike your requests) and uses callback_secret, not api_secret.

6.2 Verification order

  1. Verify the signature over the raw body bytes, constant-time compare (crypto.timingSafeEqual, hash_equals, hmac.compare_digest). Do not parse and re-stringify first.
  2. Body event_id must equal the X-WinPay-Event-Id header.
  3. request_id must be a request you opened, for that member.
  4. Insert event_id into a column with a UNIQUE index. Already there → return 2xx and stop, with no second financial movement.
  5. Apply the balance change in the same DB transaction.
  6. Commit, then return 2xx. The response body is ignored.

6.3 What to do per status

type status Action
deposit approved Credit the member approved_amount. Final
deposit rejected No balance change
deposit expired No balance change
withdrawal completed Mark paid. Not a second payment order. Final
withdrawal rejected Release / refund whatever you reserved

6.4 Delivery behaviour

Timeout 10 seconds
Redirects not followed
Max response size we read 64 KB
On failure retried with exponential backoff, up to 1 hour between attempts
On retry event_id and body are byte-identical
Protocol HTTPS only

Retries can arrive hours late. Never discard a valid event because time has passed — deduplicate on the stored event_id, not on a time window.

6.5 Transcript — deposit approved

POST /payments/callback HTTP/1.1
Host: api.your-site.example
Content-Type: application/json
X-WinPay-Signature: 9b2182c5a8155721a85bb246497c9d6a3d70c2b2c431bae1ab0fafdb23bf9aa7
X-WinPay-Event-Id: winpay:1

{"event_id":"winpay:1","event":"transaction.approved","transaction_id":1,"request_id":"dep-20260910-000001","type":"deposit","amount":"1000.00","approved_amount":"950.00","status":"approved","member_id":"member-42","timestamp":"2026-09-10T11:51:38.941Z"}

Expected from you — any 2xx, body ignored:

HTTP/1.1 200 OK

Here the member requested 1000.00 but actually paid 950.00 — credit 950.00.

6.6 Transcript — withdrawal completed

POST /payments/callback HTTP/1.1
Host: api.your-site.example
Content-Type: application/json
X-WinPay-Signature: 98f183a2e459c8f7a1069cac9349ed15947893d615846d6557fc5fb61e7336ff
X-WinPay-Event-Id: winpay:2

{"event_id":"winpay:2","event":"transaction.completed","transaction_id":2,"request_id":"wd-20260910-000001","type":"withdrawal","amount":"500.00","approved_amount":"500.00","status":"completed","member_id":"member-42","timestamp":"2026-09-10T11:51:38.957Z"}

6.7 Transcript — deposit rejected

Same shape; approved_amount is "0.00":

POST /payments/callback HTTP/1.1
Host: api.your-site.example
Content-Type: application/json
X-WinPay-Signature: c881c03a98a1c648ddc157fbb192cdd78149698ef81ba2e3fe1b948fc20a3a47
X-WinPay-Event-Id: winpay:3

{"event_id":"winpay:3","event":"transaction.rejected","transaction_id":3,"request_id":"dep-20260910-000008","type":"deposit","amount":"1000.00","approved_amount":"0.00","status":"rejected","member_id":"member-42","timestamp":"2026-09-10T11:51:38.963Z"}

7. Status values

deposit:     pending_payment -> pending_approval -> approved
                                                 -> rejected
                             -> expired (20 min)

withdrawal:  pending_assignment -> assigned -> processing -> completed
                                                          -> rejected
Status Meaning
pending_payment created, member has not reported paying
pending_approval member clicked "I paid" — not confirmation of money
approved payment verified. Final, irreversible
pending_assignment withdrawal accepted
assigned / processing being handled
completed transfer made. Final
rejected / expired no money moved

Only approved (deposit) and completed (withdrawal) mean success. A deposit never reaches completed.


8. Repeated requests (idempotency)

The idempotency key is site + transaction type + request_id.

Case Result
Same request_id, same body the existing transaction is returned with "idempotent": true in data; nothing new is created
Same request_id, different body 409 CONFLICT
request_id differs from the Idempotency-Key header 400

After a timeout do not generate a new request_id; send the same one again — it acts as a lookup and returns the existing transaction.

You may also send an Idempotency-Key header; it must equal the body's request_id.

A repeat response may omit expires_at. Deposit and withdrawal references are counted separately, but still use distinct prefixes such as dep- / wd-.


9. Errors

One shape for everything. Branch on code, never on message — messages are Turkish, human-facing, and may be reworded.

{ "success": false, "code": "BAD_REQUEST", "message": "..." }
HTTP code Meaning What to do
400 BAD_REQUEST format, amount, IBAN, reference or limit fix the data. Do not blind-retry
400 INVALID_JSON body is not valid JSON fix the serializer
401 UNAUTHORIZED bad API key, bad signature, timestamp outside window check credentials and clock
403 FORBIDDEN IP not allow-listed, blacklist, or site disabled contact us
404 NOT_FOUND wrong path check the URL
409 CONFLICT same request_id, different body inspect the existing transaction
429 TX_RATE_LIMIT init endpoints — 600/min per API key exponential backoff
429 RATE_LIMIT everything else under /api/ — 200/min per IP exponential backoff
503 SERVICE_UNAVAILABLE no account fits the amount, or maintenance surface to member, tell us if it persists
500 INTERNAL_ERROR unexpected retry once with the same request_id

RateLimit-* response headers are sent on rate-limited endpoints.

Real error responses

Sent Response
"amount": "1.000,00" 400 · Tutar: ondalık ayırıcı nokta, en fazla iki kuruş hanesi kullanın
"amount": "100.001" 400 · same message
no member_id 400 · member_id gerekli
IBAN with a bad check digit 400 · IBAN kontrol basamakları hatalı
amount above your site's limit 400 · Tutar sitenin yatırım limitleri dışında
wrong signature 401 · Istek imzasi dogrulanamadi
signature headers missing 401 · X-WinPay-Signature basligi gerekli (64 haneli hex HMAC-SHA256)
clock more than 300 s off 401 · Istek zaman damgasi 300 saniyelik pencerenin disinda; sunucu saatini kontrol edin
IP not allow-listed 403 · Bu IP site API erişimine izinli değil
member or IBAN blacklisted 403 · İşlem kabul edilmedi
no account fits the amount 503 · Bu tutar için uygun IBAN yok; tutarı veya zamanı değiştirin

10. Rate limits

Endpoint Limit Key
/deposits/init, /withdrawals/init 600 requests / minute per API key — other sites do not affect you
everything else under /api/ 200 requests / minute per IP

Above the limit you get 429; apply exponential backoff.


11. Sample client code

Node.js

const crypto = require('crypto');

async function winpayCall(path, payload) {
  const body = JSON.stringify(payload);              // serialize ONCE
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const signature = crypto
    .createHmac('sha256', process.env.WINPAY_API_SECRET)
    .update(timestamp).update('.').update(Buffer.from(body, 'utf8'))
    .digest('hex');

  const res = await fetch(process.env.WINPAY_BASE_URL + path, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-API-Key': process.env.WINPAY_API_KEY,
      'X-WinPay-Timestamp': timestamp,
      'X-WinPay-Signature': signature,
    },
    body,                                            // the SAME string
  });
  return { status: res.status, data: await res.json() };
}

// Callback verification (Express — raw body required)
app.post('/payments/callback',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const expected = crypto
      .createHmac('sha256', process.env.WINPAY_CALLBACK_SECRET)
      .update(req.body)                              // Buffer, not parsed
      .digest('hex');
    const got = req.get('X-WinPay-Signature') || '';
    if (got.length !== expected.length ||
        !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected))) {
      return res.sendStatus(401);
    }
    const event = JSON.parse(req.body.toString('utf8'));
    if (event.event_id !== req.get('X-WinPay-Event-Id')) return res.sendStatus(400);

    await db.transaction(async (t) => {
      const [, created] = await t.findOrCreate({ where: { event_id: event.event_id } });
      if (!created) return;                          // already processed
      if (event.type === 'deposit' && event.status === 'approved') {
        await creditMember(t, event.member_id, event.approved_amount);
      }
    });
    res.sendStatus(200);
  });

PHP

<?php
function winpay_call(string $path, array $payload): array {
    $body      = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    $timestamp = (string) time();
    $signature = hash_hmac('sha256', $timestamp . '.' . $body, getenv('WINPAY_API_SECRET'));

    $ch = curl_init(getenv('WINPAY_BASE_URL') . $path);
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => $body,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 20,
        CURLOPT_HTTPHEADER     => [
            'Content-Type: application/json',
            'X-API-Key: '          . getenv('WINPAY_API_KEY'),
            'X-WinPay-Timestamp: ' . $timestamp,
            'X-WinPay-Signature: ' . $signature,
        ],
    ]);
    $response = curl_exec($ch);
    $status   = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    return ['status' => $status, 'data' => json_decode($response, true)];
}

// Callback verification
$raw      = file_get_contents('php://input');        // RAW body
$expected = hash_hmac('sha256', $raw, getenv('WINPAY_CALLBACK_SECRET'));
$got      = $_SERVER['HTTP_X_WINPAY_SIGNATURE'] ?? '';
if (!hash_equals($expected, $got)) { http_response_code(401); exit; }
$event = json_decode($raw, true);

Python

import hmac, hashlib, json, time, os, requests

def winpay_call(path, payload):
    body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
    timestamp = str(int(time.time()))
    signature = hmac.new(
        os.environ["WINPAY_API_SECRET"].encode(),
        f"{timestamp}.".encode() + body.encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()

    return requests.post(
        os.environ["WINPAY_BASE_URL"] + path,
        data=body.encode("utf-8"),
        headers={
            "Content-Type": "application/json",
            "X-API-Key": os.environ["WINPAY_API_KEY"],
            "X-WinPay-Timestamp": timestamp,
            "X-WinPay-Signature": signature,
        },
        timeout=20,
    )

# Callback verification
def verify(raw_body: bytes, header_signature: str) -> bool:
    expected = hmac.new(
        os.environ["WINPAY_CALLBACK_SECRET"].encode(), raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, header_signature)

12. Verify your implementation offline

These are test values only — never used in production. They let you check your HMAC code before real credentials are issued.

api_key         = wp_test_key_0001
api_secret      = test_api_secret_do_not_use_in_production
callback_secret = test_callback_secret_do_not_use_in_production

Request signing — this timestamp and body must produce this signature:

timestamp = 1789041098
body      = {"request_id":"dep-20260910-000001","member_id":"member-42","member_name":"Ali Veli","amount":"1000.00","is_fast":false}
signature = e55ffec5d4ea23e78d86048bbd2d778349dd69efa0ea8c5961857d4ee1bba4fe

Callback verification — this body must produce this signature:

body      = {"event_id":"winpay:1","event":"transaction.approved","transaction_id":1,"request_id":"dep-20260910-000001","type":"deposit","amount":"1000.00","approved_amount":"950.00","status":"approved","member_id":"member-42","timestamp":"2026-09-10T11:51:38.941Z"}
signature = 9b2182c5a8155721a85bb246497c9d6a3d70c2b2c431bae1ab0fafdb23bf9aa7

Live smoke test once you have real credentials:

API_SECRET='<api_secret>'
API_KEY='<api_key>'
BASE='https://api.win-pay.co'

BODY='{"request_id":"dep-test-0001","member_id":"member-42","member_name":"Ali Veli","amount":"1000.00"}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$API_SECRET" -r | cut -d' ' -f1)

curl -i -X POST "$BASE/api/v1/deposits/init" \
  -H 'Content-Type: application/json' \
  -H "X-API-Key: $API_KEY" \
  -H "X-WinPay-Timestamp: $TS" \
  -H "X-WinPay-Signature: $SIG" \
  --data-raw "$BODY"

Run it twice with the same request_id: the second call must return the same transaction_id with "idempotent": true.


13. Go-live checklist

Identity and signature

Requests

Idempotency

Callback

End to end


14. Common mistakes

Mistake Result Correct
Re-building the body after signing it constant 401 serialize once, send the same string
Parsing and re-stringifying the callback JSON signature mismatch verify over the raw bytes
Crediting amount wrong amount use approved_amount
Crediting on 201 credit before money credit only on the callback
Crediting the same event_id twice double credit keep a unique event_id record
Retrying a timeout with a new request_id duplicate request retry with the same request_id
Paying again on withdrawal/completed double payment it is a notification, not an order
Putting the API key in the browser key leaks server only
Server clock without NTP random 401 set up NTP
Reusing a request_id 409 generate a unique one per request

Support

When reporting a problem, have ready: your request_id, the exact time of the request (with time zone), the HTTP status and code you received, and your server's outbound IP.

Never send secrets — do not put api_key, api_secret or callback_secret in a message, screenshot or log.