WinPay Integration DocsTürkçe →

Integration guide — code, step by step

This page builds a working integration from scratch in 7 steps. Every step has copy-paste code (Node.js; PHP and Python equivalents are in the API reference). Full field lists and error codes are in the reference.

Step 1 — Receive and store your credentials

WinPay gives you three values; keep all of them as server environment variables:

WINPAY_BASE_URL=https://api.win-pay.co
WINPAY_API_KEY=...          # identifies you
WINPAY_API_SECRET=...       # signs your requests
WINPAY_CALLBACK_SECRET=...  # verifies incoming notifications (a different value)

You give us two things: your callback URL (HTTPS) and your server's outbound IP.

Keys never go into a browser, a mobile app, HTML or logs. If one leaks, tell us and we issue new ones.

Step 2 — Check your server clock

The signature carries a timestamp; if your clock drifts more than 5 minutes, every request gets 401.

timedatectl | grep synchronized     # must say "yes"

Step 3 — The signing function

Three headers go on every request. The signature is computed over the exact bytes you send:

signature = HMAC_SHA256( <unix_seconds> + "." + <body string>, api_secret )  → 64 hex
const crypto = require('crypto');

async function winpayCall(path, payload) {
  const body = JSON.stringify(payload);              // serialize the body 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,                                            // send the SAME string
  });
  return { status: res.status, data: await res.json() };
}

Test yourself offline. With these values your function must produce this signature (test values only, never used in production):

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

If it does not match: are you re-serializing the body after signing? Key order may have changed. Sign and send the same string.

Step 4 — Write the callback endpoint (this first, then the requests)

WinPay POSTs results to this URL. The order matters:

// Express — raw body required; sign BEFORE parsing JSON
app.post('/payments/callback',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    // 1) signature: raw body + callback_secret, constant-time compare
    const expected = crypto
      .createHmac('sha256', process.env.WINPAY_CALLBACK_SECRET)
      .update(req.body)
      .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);
    }

    // 2) body and header event_id must match
    const event = JSON.parse(req.body.toString('utf8'));
    if (event.event_id !== req.get('X-WinPay-Event-Id')) return res.sendStatus(400);

    // 3) is this request_id yours?
    const record = await db.findRequest(event.request_id);
    if (!record) return res.sendStatus(400);

    // 4-5) dedupe + balance, in the SAME transaction
    await db.transaction(async (t) => {
      const fresh = await t.storeEventId(event.event_id);    // UNIQUE column
      if (!fresh) return;                                     // already processed → do nothing
      if (event.type === 'deposit' && event.status === 'approved') {
        await t.creditMember(event.member_id, event.approved_amount);   // NOT amount
      }
      if (event.type === 'withdrawal' && event.status === 'completed') {
        await t.closeWithdrawal(record.id);
      }
      if (event.type === 'withdrawal' && event.status === 'rejected') {
        await t.releaseReservation(record.id);
      }
      await t.setStatus(record.id, event.status);
    });

    // 6) 200 AFTER the balance is written
    res.sendStatus(200);
  });

The callback body looks like this (same shape for deposits and withdrawals):

{
  "event_id": "winpay:901",
  "event": "transaction.approved",
  "transaction_id": 201,
  "request_id": "dep-20260910-000001",
  "type": "deposit",
  "amount": "1000.00",
  "approved_amount": "950.00",
  "status": "approved",
  "member_id": "member-42",
  "timestamp": "2026-09-10T16:45:00.000Z"
}

Offline test: with callback_secret = test_callback_secret_do_not_use_in_production 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

Step 5 — Deposit flow

async function startDeposit(member, amount) {
  const requestId = `dep-${Date.now()}-${member.id}`;        // unique, store it
  await db.createRequest({ requestId, memberId: member.id, amount, type: 'deposit', status: 'pending' });

  const { status, data } = await winpayCall('/api/v1/deposits/init', {
    request_id: requestId,
    member_id: String(member.id),       // send as a string
    member_name: member.fullName,
    amount: amount.toFixed(2),          // "1000.00"
  });

  if (status === 201) return data.data.payment_url;   // redirect the member here
  if (status === 429) throw new Error('Try again shortly');
  if (status === 503) throw new Error('Temporarily unavailable');
  throw new Error(`WinPay ${status}: ${data.code}`);
}

Then res.redirect(paymentUrl) — a top-level redirect, not an iframe. Do not write the link to logs or analytics. Credit the balance in the callback, not here.

On a timeout, resend with the same request_id; no second transaction is created, the existing one is returned ("idempotent": true).

Step 6 — Withdrawal flow

async function startWithdrawal(member, amount, iban, accountName) {
  if (member.balance < amount) throw new Error('Insufficient balance');
  const requestId = `wd-${Date.now()}-${member.id}`;
  await db.transaction(async (t) => {
    await t.reserve(member.id, amount);                  // deduct, status "pending"
    await t.createRequest({ requestId, memberId: member.id, amount, type: 'withdrawal', status: 'pending' });
  });

  const { status, data } = await winpayCall('/api/v1/withdrawals/init', {
    request_id: requestId,
    member_id: String(member.id),
    member_name: member.fullName,
    amount: amount.toFixed(2),
    iban,                                // TR + 24 digits, no spaces
    account_name: accountName,
  });

  if (status === 201) return data.data.transaction_id;  // result comes by callback
  await db.releaseReservation(member.id, amount);        // request not accepted → give it back
  throw new Error(`WinPay ${status}: ${data.code}`);
}

201 = request accepted, money has not moved yet. The withdrawal ends when the callback says completed; on rejected you release the reservation. Never send a second request.

Step 7 — Test, then go live

Run the list on Testing, FAQ, troubleshooting. The four that matter most:

  1. You reject a callback with a bad signature.
  2. When the same event_id arrives twice, the balance is not credited again.
  3. You use approved_amount, not amount.
  4. You credit on the callback, not on 201.

First call with 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"

If you see 201 with a payment_url, the integration works. Run it a second time: the same transaction_id with "idempotent": true must come back.