Getting Started
The Dodopin Payment API allows you to embed a secure, hosted checkout experience into your platform. Your server generates a signed checkout session, redirects the end-user to the hosted payment page, and receives a cryptographically signed webhook notification upon payment completion — no card data ever touches your infrastructure.
Credentials
OK.
Integration Flow
-
1
Generate session — From your server, POST customer data + HMAC-SHA256 hash to
/checkout/token.php. On success you receive a signedtokenand aredirectURL. The token is valid for 2 hours. -
2
Redirect user — Immediately forward the end-user's browser to the
redirectURL returned in step 1. Do not store or reuse the token. - 3 Hosted checkout — The customer selects a product and completes payment on the Dodopin hosted page. No sensitive payment data touches your infrastructure.
-
4
Receive IPN webhook — After successful payment, Dodopin sends a signed
HTTP POST to your registered Webhook URL. Verify the
hashsignature, checkstatus === "success", and fulfill the order usingorder_ref. Respond with HTTP 200 and bodyOK.
api_secret in front-end JavaScript or mobile app code. All requests to /checkout/token.php must originate from your backend server.
Authentication
The API uses HMAC-SHA256 signatures in two places:
(1) you sign every Create Session request so your api_secret
never travels over the wire, and
(2) the platform signs every webhook it dispatches so you can verify
the notification is genuine.
Request Hash (Create Session)
| delimiter between each field, in the exact order shown.
Pass the raw binary HMAC digest to base64_encode — do not hex-encode first.
Your api_secret is the signing key and must never be sent in the request body.
Webhook Signature Formula
Webhook Verification Example
$API_KEY = 'YOUR_API_KEY';
$API_SECRET = 'YOUR_API_SECRET';
// All fields come directly from the flat POST body
$hash_string =
($_POST['merchant_id'] ?? '') .
($_POST['order_ref'] ?? '') .
($_POST['user_fullname'] ?? '') .
($_POST['invoice_mail'] ?? '') .
($_POST['gateway_name'] ?? '') .
($_POST['status'] ?? '') .
$API_KEY;
$expected = base64_encode(
hash_hmac('sha256', $hash_string, $API_SECRET, true)
);
// hash_equals() prevents timing attacks
if (!hash_equals($expected, $_POST['hash'] ?? '')) {
http_response_code(403);
die('invalid_hash');
}
const crypto = require('crypto');
// All fields come directly from the flat POST body
const hashString =
String(req.body.merchant_id ?? '') +
String(req.body.order_ref ?? '') +
String(req.body.user_fullname ?? '') +
String(req.body.invoice_mail ?? '') +
String(req.body.gateway_name ?? '') +
String(req.body.status ?? '') +
API_KEY;
const expected = Buffer
.from(crypto.createHmac('sha256', API_SECRET).update(hashString).digest())
.toString('base64');
// timingSafeEqual prevents timing attacks
const a = Buffer.from(req.body.hash ?? '');
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(403).send('invalid_hash');
}
import hmac, hashlib, base64
from flask import request
API_KEY = 'YOUR_API_KEY'
API_SECRET = 'YOUR_API_SECRET'
# All fields come directly from the flat POST body
f = lambda k: request.form.get(k, '')
hash_string = (
f('merchant_id') +
f('order_ref') +
f('user_fullname') +
f('invoice_mail') +
f('gateway_name') +
f('status') +
API_KEY
)
expected = base64.b64encode(
hmac.new(API_SECRET.encode(), hash_string.encode(), hashlib.sha256).digest()
).decode()
# compare_digest prevents timing attacks
if not hmac.compare_digest(f('hash'), expected):
abort(403)
Create Checkout Session
POST your credentials and customer context to the token endpoint from your server.
On success, the API returns a signed, single-use redirect URL.
Forward the end-user's browser to this URL to begin the hosted payment flow.
Code Examples
<?php
$API_KEY = 'YOUR_API_KEY';
$API_SECRET = 'YOUR_API_SECRET';
$store_id = 12345;
$user_id = 678;
$username = 'username';
$user_email = '[email protected]';
$hash = base64_encode(
hash_hmac('sha256', $API_KEY . '|' . $store_id . '|' . $user_id . '|' . $username . '|' . $user_email, $API_SECRET, true)
);
$post_data = [
'api_key' => $API_KEY,
'hash' => $hash,
'store_id' => $store_id,
'user_id' => $user_id,
'username' => $username,
'user_email' => $user_email,
'user_ip' => $_SERVER['REMOTE_ADDR'] ?? '',
'user_fullname' => 'John Doe',
'user_phone' => '+13125550100',
'lang' => 'tr', // page opening language (default: tr)
'currency' => 'TRY', // page opening currency (default: TRY)
];
$ch = curl_init('https://dodopin.com/checkout/token.php');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $post_data,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_TIMEOUT => 30,
]);
$response = json_decode(curl_exec($ch), true);
curl_close($ch);
if ($response['success'] ?? false) {
header('Location: ' . $response['redirect']);
exit;
}
throw new RuntimeException('Token error: ' . ($response['message'] ?? 'unknown'));
?>
const https = require('https');
const qs = require('querystring');
const crypto = require('crypto');
const API_KEY = 'YOUR_API_KEY';
const API_SECRET = 'YOUR_API_SECRET';
const store_id = 12345;
const user_id = 678;
const username = 'username';
const user_email = '[email protected]';
const hash = Buffer
.from(crypto.createHmac('sha256', API_SECRET)
.update(String(API_KEY) + '|' + String(store_id) + '|' + String(user_id) + '|' + username + '|' + user_email)
.digest())
.toString('base64');
const body = qs.stringify({
api_key: API_KEY,
hash: hash,
store_id: store_id,
user_id: user_id,
username: username,
user_email: user_email,
user_ip: '1.2.3.4',
user_fullname: 'John Doe',
user_phone: '+393331234567',
lang: 'tr', // page opening language (default: tr)
currency: 'TRY', // page opening currency (default: TRY)
});
const url = new URL('https://dodopin.com/checkout/token.php');
const req = https.request({
hostname: url.hostname,
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(body),
},
}, (res) => {
let data = '';
res.on('data', c => data += c);
res.on('end', () => {
const json = JSON.parse(data);
if (json.success) console.log('Redirect →', json.redirect);
else throw new Error(json.message);
});
});
req.on('error', err => { throw err; });
req.write(body);
req.end();
import requests, hmac, hashlib, base64
API_KEY = 'YOUR_API_KEY'
API_SECRET = 'YOUR_API_SECRET'
store_id = 12345
user_id = 678
username = 'username'
user_email = '[email protected]'
hash_string = API_KEY + '|' + str(store_id) + '|' + str(user_id) + '|' + username + '|' + user_email
hash_val = base64.b64encode(
hmac.new(API_SECRET.encode(), hash_string.encode(), hashlib.sha256).digest()
).decode()
resp = requests.post(
'https://dodopin.com/checkout/token.php',
data={
'api_key': API_KEY,
'hash': hash_val,
'store_id': store_id,
'user_id': user_id,
'username': username,
'user_email': user_email,
'user_ip': '1.2.3.4',
'user_fullname': 'John Doe',
'user_phone': '+201001234567',
'lang': 'tr', # page opening language (default: tr)
'currency': 'TRY', # page opening currency (default: TRY)
},
timeout=30,
)
resp.raise_for_status()
data = resp.json()
if data.get('success'):
redirect_url = data['redirect'] # redirect user here
else:
raise RuntimeError(data.get('message'))
# Compute hash first (shell)
HASH=$(printf '%s' 'YOUR_API_KEY|12345|678|username|[email protected]' \
| openssl dgst -sha256 -hmac 'YOUR_API_SECRET' -binary | base64)
curl -X POST 'https://dodopin.com/checkout/token.php' \
-d 'api_key=YOUR_API_KEY' \
--data-urlencode "hash=$HASH" \
-d 'store_id=12345' \
-d 'user_id=678' \
-d 'username=username' \
-d '[email protected]' \
-d 'user_ip=1.2.3.4' \
-d 'user_fullname=John+Doe' \
-d 'user_phone=%2B13125550100' \
-d 'lang=tr' \
-d 'currency=TRY'
Response
{
"success": true,
"token": "eyJzIjoxMjM0NX0...",
"redirect": "https://dodopin.com/tr/store/your-store-slug?token=eyJzIjoxMjM0NX0...&cur=TRY"
}
{
"success": false,
"message": "Invalid API credentials or store is not active."
}
Request Parameters
| Field | Type | Description | Validation | Required |
|---|---|---|---|---|
api_key |
String | Your store API key, found in Store Settings → Integration. | Non-empty string | required |
hash |
String | HMAC-SHA256 request signature. See Authentication for the formula. Your api_secret is the signing key — never send it in the request body. |
base64_encode(HMAC-SHA256(api_key|store_id|user_id|username|user_email, api_secret)) — fields joined with pipe | |
required |
store_id |
Integer | Numeric ID of your store, found in Store Settings → Integration. | Positive integer | required |
user_id |
String / Int | The customer's unique identifier on your platform. Used to identify the buyer in webhook notifications. | Max 64 chars. Control characters are stripped. | required |
username |
String | The customer's username as it appears on your platform. Displayed on the checkout page. | Letters, digits, _ and - only. Max 64 chars. Spaces and special characters are rejected. |
required |
user_email |
String | The customer's e-mail address. Used for the payment invoice. | Must be a valid e-mail address (RFC 5321). | required |
user_ip |
String | The end-user's IP address. Pass the real client IP, not your server IP. | Must be a valid IPv4 or IPv6 address. | required |
user_fullname |
String | The customer's full name (first and last name). Used on the payment invoice. | Max 128 chars. Control characters are stripped. | required |
user_phone |
String | The customer's phone number including country code. | Digits and + only. Max 20 chars. Must contain at least one digit(e.g. +13125550100, +393331234567, +201001234567). |
required |
lang |
String | Page opening language. Default: tr |
tr, en |
optional |
currency |
String | Page opening currency. Default: TRY |
TRY, USD, EUR |
optional |
Webhooks (IPN)
After each successful payment, the platform dispatches an HTTP POST to your registered
Webhook URL with signed order, customer, and payment data.
Your endpoint must verify the hash signature, fulfill the order when
status = success, and respond with HTTP 200 and body
OK. Non-compliant responses trigger automatic retries.
Handler Implementation
<?php
$API_KEY = 'YOUR_API_KEY';
$API_SECRET = 'YOUR_API_SECRET';
// Payload is a flat POST body — all fields at root level
$merchant_id = $_POST['merchant_id'] ?? '';
$order_ref = $_POST['order_ref'] ?? '';
$user_fullname = $_POST['user_fullname'] ?? '';
$invoice_mail = $_POST['invoice_mail'] ?? '';
$gateway_name = $_POST['gateway_name'] ?? '';
$status = $_POST['status'] ?? '';
$received_hash = $_POST['hash'] ?? '';
$expected = base64_encode(hash_hmac('sha256',
$merchant_id . $order_ref . $user_fullname .
$invoice_mail . $gateway_name . $status . $API_KEY,
$API_SECRET, true
));
if (!hash_equals($expected, $received_hash)) {
http_response_code(403);
die('invalid_hash');
}
if ($status === 'success') {
$topup = (float) ($_POST['total_topup_amount'] ?? '0');
$user = $_POST['username'] ?? '';
// Credit $topup to the player's in-game balance
// e.g. addBalance($user, $topup);
}
header('Content-Type: text/plain');
echo 'OK';
?>
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.urlencoded({ extended: true }));
app.post('/webhook', (req, res) => {
// Payload is a flat POST body — all fields at root level
const {
merchant_id = '',
order_ref = '',
user_fullname = '',
invoice_mail = '',
gateway_name = '',
status = '',
hash = '',
} = req.body;
const expected = Buffer.from(crypto
.createHmac('sha256', process.env.API_SECRET)
.update(merchant_id + order_ref + user_fullname + invoice_mail + gateway_name + status + process.env.API_KEY)
.digest()
).toString('base64');
const hBuf = Buffer.from(hash);
const eBuf = Buffer.from(expected);
if (hBuf.length !== eBuf.length || !crypto.timingSafeEqual(hBuf, eBuf))
return res.status(403).send('invalid_hash');
if (status === 'success') {
const topup = parseFloat(req.body.total_topup_amount || '0');
const user = req.body.username || '';
// Credit topup to the player's in-game balance
// e.g. addBalance(user, topup);
}
res.type('text').send('OK');
});
app.listen(3000);
import hmac, hashlib, base64
from flask import Flask, request, abort
app = Flask(__name__)
@app.route('/webhook', methods=['POST'])
def webhook():
# Payload is a flat POST body — all fields at root level
f = lambda k: request.form.get(k, '')
hash_string = (
f('merchant_id') +
f('order_ref') +
f('user_fullname') +
f('invoice_mail') +
f('gateway_name') +
f('status') +
API_KEY
)
expected = base64.b64encode(
hmac.new(API_SECRET.encode(), hash_string.encode(), hashlib.sha256).digest()
).decode()
if not hmac.compare_digest(f('hash'), expected):
abort(403)
if f('status') == 'success':
topup = float(f('total_topup_amount') or '0')
user = f('username')
# Credit topup to the player's in-game balance
# e.g. add_balance(user, topup)
return 'OK', 200, {'Content-Type': 'text/plain'}
Payload Fields
| Field | Type | Description | In Hash |
|---|---|---|---|
merchant_id | String | Your Store / Merchant ID | yes — pos 1 |
order_ref | String | Unique order reference generated by Dodopin | yes — pos 2 |
user_fullname | String | Customer full name | yes — pos 3 |
invoice_mail | String | Customer e-mail address | yes — pos 4 |
gateway_name | String | Payment gateway identifier (e.g. stripe, cryptomus, iyzico) | yes — pos 5 |
status | String | Always success for automatic webhooks | yes — pos 6 |
api_key | String | Your store API key — not sent in the payload, but appended to the hash string as position 7. Use your stored api_key when verifying the signature. | yes — pos 7 |
user_phone | String | Customer phone number with country code | no |
product_id | Integer | Product ID — omitted if not applicable to the order | no |
product_name | String | Product title as stored at checkout (product_name_snapshot) — omitted if empty | no |
quantity | String | Order quantity (min 1) | no |
product_topup_amount | String | Unit topup/credit amount per product (e.g. 1.00) | no |
total_topup_amount | String | Total credit to add to the user's account. Equals quantity × product_topup_amount (e.g. 250.00). Use this value to top up / credit the buyer in your game or platform. | no |
product_currency | String | Product currency code (e.g. TRY, USD, EUR) | no |
unit_price | String | Unit price per product (e.g. 49.90) | no |
total_price | String | Total order price: quantity × unit_price (e.g. 149.70) | no |
net_merchant_earning | String | Net merchant earning after commission deduction (e.g. 127.25) | no |
username | String | Game / account username when available (mirrors checkout token field username) | no |
hash | String | HMAC-SHA256 base64 signature — always verify before processing | — |
total_topup_amount field to determine
how much credit/balance to add to the user identified by username
(or user_id from your original token request).
This is the definitive amount the customer purchased — apply it directly to the player's
in-game wallet or balance.
Error Codes
The token endpoint always returns HTTP 200. Evaluate the success
boolean to determine the outcome. Every error response includes a
message string describing the failure.
invalid_hash — do not process these notifications.
Error Reference
| Scenario | HTTP | success | message | Common Cause |
|---|---|---|---|---|
| Any required field is empty or missing | 200 | false | Missing required field: <field_name>. |
Field not included in POST body |
username contains invalid characters |
200 | false | Invalid value for username. |
Spaces, Turkish chars, or symbols in username. Only a-z A-Z 0-9 _ - allowed. |
user_email is not a valid address |
200 | false | Invalid value for user_email. |
Malformed e-mail address |
user_ip is not a valid IP address |
200 | false | Invalid value for user_ip. |
Sending server IP instead of client IP, or invalid format |
Wrong api_key / store_id combination, or store is inactive |
200 | false | Invalid API credentials or store is not active. |
api_key does not match store_id, or store status is not approved_admin |
| Hash signature mismatch | 200 | false | Invalid hash. Please recalculate… |
Wrong field order, wrong api_secret, or missing pipe delimiter. Fields must be joined with | (pipe) in the exact order shown. |
| Webhook signature mismatch | 403 | — | Plain text: invalid_hash |
Your endpoint rejected the webhook due to a bad signature — do not process these |
Rate Limits
The token endpoint enforces three independent rate-limit layers. All layers must pass
before a session is created. Exceeding any returns HTTP 429 with a
Retry-After header indicating when to retry.
Limit Layers
| Layer | Scope | Default Limit | Window | HTTP |
|---|---|---|---|---|
| Fast IP Limit | Per client IP address | 60 requests | 60 seconds | 429 |
| Token IP Limit | Per client IP address | 30 requests | 1 minute | 429 |
| Token API Key Limit | Per api_key |
60 requests | 1 minute | 429 |
Best Practices
- 1 Create sessions on demand — only generate a token when the user is actively about to pay. Never pre-generate tokens in batch.
-
2
Respect
Retry-After— read the header value and delay your retry exactly that many seconds. Immediate retries will continue to be rejected and waste your quota. - 3 Do not cache and reuse tokens — tokens are single-use and expire after 2 hours. Reusing or sharing tokens across users is not supported.
-
4
Use exponential back-off — if you receive a
429, implement exponential back-off with jitter to avoid thundering-herd retries.
API Reference
Technical summary of endpoints, methods, response formats, and security requirements.
/checkout/token.php
form-urlencoded
JSON
Retry-After
OK 200 text/plain