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:
- You reject a callback with a bad signature.
- When the same
event_idarrives twice, the balance is not credited again. - You use
approved_amount, notamount. - 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.