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
201response 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
api_keyandapi_secretlive only on your server. Never in HTML, a mobile app bundle or the browser.- The three secrets are different values and are not interchangeable.
- Keep your server clock on NTP — the signature uses a time window.
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
- Do not build the token yourself; use the
payment_urlfrom the response. - Use a top-level redirect (do not assume it works inside an iframe).
- Never put the link in analytics, chat, referrers or logs — anyone with the link sees the page.
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_amountfrom the callback — never theamountyou 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"
}
}
201means 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, notapi_secret.
6.2 Verification order
- 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. - Body
event_idmust equal theX-WinPay-Event-Idheader. request_idmust be a request you opened, for that member.- Insert
event_idinto a column with a UNIQUE index. Already there → return2xxand stop, with no second financial movement. - Apply the balance change in the same DB transaction.
- 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 asdep-/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
- Wrong
api_key→ 401 - Unsigned request → 401
- Corrupted signature → 401
- Clock moved 10 minutes ahead → 401
- Request from an IP outside the allow-list → 403
Requests
- Valid deposit → 201 +
payment_url - Valid withdrawal → 201
-
amountof-100,0,1.001,"1.000,00",abc→ all 400 - Invalid IBAN → 400
Idempotency
- Same
request_id+ same body → one transaction,idempotent: true - Same
request_id+ different amount → 409
Callback
- Signature verified over the raw body
- Notification with a bad signature is rejected
- Same
event_idtwice → no second credit -
deposit/approvedcreditsapproved_amount -
withdrawal/completeddoes not trigger a second payment -
2xxreturned after the balance is written
End to end
- Deposit: request → payment page → callback → balance
- Deposit with a different amount chosen:
approved_amounthandled correctly - Expired deposit:
expiredarrives, balance unchanged - Withdrawal: request →
completed→ withdrawal closed
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.