Getting Started API v2

Credentials
API Key
api_key
Public
Identifies your store in all API requests and outbound webhook deliveries.
API Secret
api_secret
Secret
HMAC-SHA256 signing key for Create Session and webhook verification. Never expose client-side.
Store ID
store_id
Public
Your numeric store identifier, required in every session request.

Integration Flow

  • 1 Generate session — From your server, POST customer data + HMAC-SHA256 hash to /checkout/token.php. On success you receive a signed token and a redirect URL. The token is valid for 2 hours.
  • 2 Redirect user — Immediately forward the end-user's browser to the redirect URL returned in step 1 (recommended). Alternatively, load that URL inside an iframe. 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 hash signature, check status === "success", and fulfill the order using order_ref. Respond with HTTP 200 and body OK.
Server-side only. Never generate the hash or expose api_secret in front-end JavaScript or mobile app code. All requests to /checkout/token.php must originate from your backend server.

iframe Embedding

Full-page redirect is the recommended integration. You may also load the hosted store URL in an iframe for product selection. Framing is allowed (frame-ancestors *).

How it works

  • 1 Create a checkout session from your server exactly as in Create Session. Use the returned redirect URL as the iframe src.
  • 2 When the URL includes a valid session token, the hosted store hides its own header and footer automatically so the checkout fits cleanly inside your page. Any outer chrome (back button, title, “open in browser”) is yours — Dodopin does not inject it.
  • 3 On payment method submit (e.g. Continue / Devam et), Dodopin tries to open the PSP in the top-level window; if the host blocks that, it opens a new window (system browser); otherwise it stays in the frame. For this to work your embedding context must allow top navigation or popups. If you use a sandbox attribute, include at least allow-scripts allow-same-origin allow-forms allow-top-navigation allow-popups. Game engines / in-app WebViews that block both top navigation and popups must open checkout in the system browser instead — otherwise the payment button appears to do nothing.
  • 4 Fulfill orders from the signed webhook — not from iframe UI events or return-page JavaScript. The customer may finish payment outside your frame.

Example

<!-- redirect = value from Create Session JSON -->
<!-- Do not omit allow-top-navigation if you add sandbox="" -->
<iframe
  id="dodopin-checkout"
  src="https://dodopin.com/tr/store/your-store-slug?token=…&cur=TRY"
  title="Dodopin Checkout"
  style="width:100%;min-width:1024px;height:900px;border:0;overflow:auto;"
  scrolling="yes"
  allow="payment *"
  referrerpolicy="strict-origin-when-cross-origin"
></iframe>
<script>
window.addEventListener("message", function (e) {
  if (!e.data || e.data.source !== "dodopin") return;
  var f = document.getElementById("dodopin-checkout");
  if (!f) return;
  if (e.data.type === "embed-height" && e.data.height) {
    f.style.height = e.data.height + "px";
  }
  if (e.data.type === "embed-scroll-into-view") {
    try { f.scrollIntoView({ block: "nearest", inline: "nearest" }); } catch (err) {}
  }
});
</script>
Serve your parent page over HTTPS. Give the iframe enough room: at least 1024px wide and about 900px tall, and do not disable scrolling (scrolling="no" or overflow:hidden). The hosted checkout scrolls inside the frame; it also posts {source:"dodopin", type:"embed-height", height} so you can grow the iframe with the content, and {source:"dodopin", type:"embed-scroll-into-view"} when the order form opens so the frame stays on screen. Below ~992px width the hosted checkout switches to its compact (mobile) layout.
Payment button seems dead? Stripe Checkout and most bank / 3-D Secure pages set X-Frame-Options / frame-ancestors so they cannot be nested. Dodopin escalates: window.top → new window → same frame. If your host (especially a game engine WebView) blocks both top navigation and popups, the payment step cannot proceed inside the frame — open the checkout in the system browser or use a full-page redirect. Do not rely on postMessage for payment confirmation; use webhooks.

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)

  hash_string =
 api_key
+ "|" +store_id
+ "|" +user_id
+ "|" +username
+ "|" +user_email
hash = base64_encode( HMAC_SHA256( hash_string, api_secret ))
Concatenate fields with a pipe | 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

  hash_string =
 merchant_id
+order_ref
+user_fullname
+invoice_mail
+gateway_name
+status
+api_key
hash = base64_encode( HMAC_SHA256( hash_string, api_secret ))

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.

POST https://dodopin.com/checkout/token.php Content-Type: form-urlencoded → JSON

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

200 — Success
{
  "success": true,
  "token": "eyJzIjoxMjM0NX0...",
  "redirect": "https://dodopin.com/tr/store/your-store-slug?token=eyJzIjoxMjM0NX0...&cur=TRY"
}
200 — Error
{
  "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 My Stores → Edit Store → Store's API Info. 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 My Stores → Edit Store → Store's API Info. 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, de, fr, es, it, pt, nl, pl, ro, cs optional
currency String Page opening currency. Default: TRY AED, AMD, ARS, AUD, AZN, BGN, BHD, BRL, CAD, CHF, CLP, CNY, COP, CZK, DKK, EGP, EUR, GBP, GEL, GHS, HKD, HUF, IDR, ILS, INR, ISK, JOD, JPY, KES, KRW, KWD, KZT, MAD, MXN, MYR, NGN, NOK, NZD, OMR, PEN, PHP, PKR, PLN, QAR, RON, RUB, SAR, SEK, SGD, THB, TND, TRY, UAH, USD, UYU, VND, ZAR, HRK optional

Webhooks (IPN) API v2

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.

API v2. The webhook sends a single username field (no duplicate user_name) and an api_version of 2. Hash verification is unchanged. Choose v2 in My Stores → Edit Store. Existing stores stay on v1 until you switch.
Register your Webhook URL in My Stores → Edit Store. The endpoint must be publicly reachable, TLS-secured, and respond within 15 seconds. Webhooks are only dispatched on successful payments. On failure, the system retries up to 100 times, once per minute via a background cron job.

Example POST body

POST Your Webhook URL Content-Type: application/x-www-form-urlencoded
{
    "order_ref": "O14092026ABCDEF1",
    "merchant_id": "12345",
    "status": "success",
    "gateway_name": "payop",
    "product_id": "42",
    "product_name": "13000 Diamonds",
    "quantity": "1",
    "product_topup_amount": "13000.00",
    "total_topup_amount": "13000.00",
    "product_currency": "EUR",
    "unit_price": "50.00",
    "total_price": "50.00",
    "net_merchant_earning": "47.50",
    "user_fullname": "John Doe",
    "invoice_mail": "[email protected]",
    "user_id": "678",
    "user_phone": "+13125550100",
    "username": "username",
    "api_version": "2",
    "hash": "oIJlok5zdmQtIK19QKqEL9uGo/xQGI/ArQNq1lwVg=="
}

The live request is a flat form POST (application/x-www-form-urlencoded), not a JSON body. The object below is that same payload shown as JSON — identical to the outgoing webhook panel on the order page. api_key is not in the body; use your stored key only when verifying hash.

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

FieldTypeDescriptionIn Hash
merchant_idStringYour Store / Merchant IDyes — pos 1
order_refStringUnique order reference generated by Dodopinyes — pos 2
user_fullnameStringCustomer full nameyes — pos 3
invoice_mailStringCustomer e-mail addressyes — pos 4
gateway_nameStringPayment gateway identifier (e.g. stripe, tazapay, lidio)yes — pos 5
statusStringAlways success for automatic webhooksyes — pos 6
api_keyStringYour 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_phoneStringCustomer phone number with country codeno
product_idIntegerProduct ID — omitted if not applicable to the orderno
product_nameStringProduct title as stored at checkout (product_name_snapshot) — omitted if emptyno
quantityStringOrder quantity (min 1)no
product_topup_amountStringUnit topup/credit amount per product (e.g. 1.00)no
total_topup_amountStringTotal 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_currencyStringProduct currency code (e.g. TRY, USD, EUR)no
unit_priceStringUnit price per product (e.g. 49.90)no
total_priceStringTotal order price: quantity × unit_price (e.g. 149.70)no
net_merchant_earningStringNet merchant earning after commission deduction (e.g. 127.25)no
user_idStringCustomer ID from your Create Session request — omitted if unknownno
usernameStringGame / account username when available (mirrors checkout token field username)no
api_versionStringAlways 2 on v2 deliveries. Not included in the hash.no
hashStringHMAC-SHA256 base64 signature — always verify before processing—
Crediting the player. After verifying the webhook signature, read the 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.

Webhook signature mismatches should be rejected with HTTP 403 and body invalid_hash — do not process these notifications.

Error Reference

ScenarioHTTPsuccessmessageCommon 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
Limits shown are defaults and may be tuned per-store by the platform administrator. Server-to-server calls from your backend count against the client IP layer — ensure your backend's outbound IP is consistent and not shared across many services.

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.