# PayTR API: iFrame token, callback and test mode in Python

URL: https://fincodelabs.com/en/blog/paytr-api-integration · Publisher: Fincode (Fincode Yazılım ve Teknoloji A.Ş.), Istanbul, Turkey · Category: Fintech development · Published: 2026-11-06 · Updated: 2026-11-06

PayTR API guide: exact iFrame token order, callback hash, repeated notifications, test mode, amount formats and official sample errors, with tested Python code.

**Short answer:** The **PayTR API** iFrame flow starts with a server-side POST to /odeme/api/get-token, signed with a base64 HMAC-SHA256 token, and ends at your callback URL, which must verify a hash and answer with plain OK. As read on 6 November 2026, PayTR repeats an unacknowledged callback after a minute, so each merchant_oid must be processed once. The pages read describe testing through test_mode on a merchant store and show no separate sandbox host.

This guide comes from the PayTR API docs at dev.paytr.com/en, read on 6 November 2026; the code ran on synthetic values, with no live call and no merchant store. The e-money register of the Central Bank of the Republic of Türkiye (TCMB) lists the company as Paytr Ödeme ve Elektronik Para Kuruluşu A.Ş., code 863, ticked for payment services (a), (b), (c) and (e) of Article 12(1) of Law No. 6493 and for e-money issuance. PayTR's own site says it is licensed under that law and supervised by TCMB.

This guide is not a recommendation or comparison and has no referral link. Who may collect payments is covered in the [Turkey payment gateway guide](https://fincodelabs.com/en/blog/turkey-payment-gateway); crypto is not a payment method in Turkey, as the crypto payment guide explains.

## PayTR API overview: which API does what

Each service signs its own string with merchant_key, always adding merchant_salt, and amount formats differ.

**Services, token strings and amounts in the docs read 6 November 2026. Token = base64 of HMAC-SHA256(merchant_key, string).**

| API | POST to | String signed | Amount format |
| --- | --- | --- | --- |
| iFrame | `/odeme/api/get-token` | merchant_id, user_ip, merchant_oid, email, payment_amount, user_basket, no_installment, max_installment, currency, test_mode, merchant_salt | Integer times 100: 3456 for 34.56 |
| Direct | `/odeme`, from the buyer's browser | merchant_id, user_ip, merchant_oid, email, payment_amount, payment_type, installment_count, currency, test_mode, non_3d, merchant_salt | Decimal: 100.99 |
| Link create | `/odeme/api/link/create` | name, price, currency, max_installment, link_type, lang, then min_count or email, merchant_salt | Times 100 |
| Status inquiry | `/odeme/durum-sorgu` | merchant_id, merchant_oid, merchant_salt | Not sent |
| Refund | `/odeme/iade` | merchant_id, merchant_oid, return_amount, merchant_salt | Decimal: 11.90 |
| Callback, iFrame and Direct | Your URL | merchant_oid, merchant_salt, status, total_amount | Times 100 |

The PayTR API key is really three values, merchant_id, merchant_key and merchant_salt, which [PayTR's overview](https://dev.paytr.com/en/iframe-api) says the Main User and Technical User can access in the merchant panel. Keep the last two on your server, as [API key security](https://fincodelabs.com/en/blog/api-key-security-trading-bots) explains.

## PayTR iFrame API: token order and field traps

A token breaks when a signed field differs by one byte from what you post.

**iFrame Step 1 fields, read 6 November 2026**

| Field | Documented rule | Trap |
| --- | --- | --- |
| user_ip | The buyer's external address, up to 39 characters | Locally, a sample warns, an internal address breaks the token. Behind a proxy, send one address, not a list |
| payment_amount | Amount times 100; the FAQ says the minimum is TRY 1.00 | In floating point, `int(19.99 * 100)` gives 1998, so use decimals |
| merchant_oid | Unique, up to 64 characters, alphanumeric | Hyphens and underscores fall outside that wording |
| email | Up to 100 characters, no Turkish characters | Check it before you sign |
| user_basket | Base64 of a JSON array of name, unit price and quantity | Sign the same base64 string you post |
| max_installment | 0 means the maximum available | The docs' jewelry example of 4 is stale: banking regulator BDDK cut it to 3 in July 2021, none on coins or bullion |
| debug_on, timeout_limit | Not part of the token; timeout defaults to 30 minutes | Set debug_on to 1 during integration to see reasons |

A successful reply is JSON with `status` and `token`. Open `https://www.paytr.com/odeme/guvenli/{token}` in the iFrame; a failure carries `reason`.

## A tested PayTR token builder and callback verifier in Python

This code builds the iFrame fields and processes a callback once; it ran on Python 3.11 on 6 November 2026 with synthetic values and no network.

```python
import base64, hashlib, hmac, ipaddress, json, re
from decimal import Decimal

MERCHANT_ID, KEY, SALT = "123456", b"synthetic-key", "synthetic-salt"  # not real values

def b64hmac(message):
    return base64.b64encode(hmac.new(KEY, message.encode(), hashlib.sha256).digest()).decode()

def kurus(amount):                     # "34.56" -> "3456" without float math
    if not (isinstance(amount, str) and re.fullmatch(r"[0-9]+(\.[0-9]{1,2})?", amount)
            and Decimal(amount) >= 1):  # FAQ minimum: TRY 1.00
        raise ValueError(f"bad amount {amount}")
    return str(int(Decimal(amount + "e2")))

def iframe_fields(user_ip, oid, email, amount, basket, no_inst="0", max_inst="0",
                  currency="TL", test_mode="1"):
    ipaddress.ip_address(user_ip)      # ValueError on "a, b" lists
    if not (oid.isascii() and oid.isalnum() and len(oid) <= 64 and email.isascii()):
        raise ValueError("bad merchant_oid or email")
    user_basket = base64.b64encode(json.dumps(basket).encode()).decode()
    payment_amount = kurus(amount)
    hash_str = (MERCHANT_ID + user_ip + oid + email + payment_amount + user_basket
                + no_inst + max_inst + currency + test_mode)
    return {"merchant_id": MERCHANT_ID, "user_ip": user_ip, "merchant_oid": oid,
            "email": email, "payment_amount": payment_amount, "user_basket": user_basket,
            "no_installment": no_inst, "max_installment": max_inst, "currency": currency,
            "test_mode": test_mode, "paytr_token": b64hmac(hash_str + SALT)}

orders = {"ORD1": {"due": "3456", "state": "open", "shipped": 0}}  # use a database with a lock

def callback(post, live=True):
    oid, status, total = (post.get(k, "") for k in ("merchant_oid", "status", "total_amount"))
    good = b64hmac(oid + SALT + status + total)
    if not hmac.compare_digest(good.encode(), post.get("hash", "").encode()):
        return "bad hash"              # no OK, so PayTR retries
    order = orders.get(oid)
    if order is None:
        return "unknown order"         # no OK: stays In Progress for review
    if not order["state"] == "open":
        return "OK"                    # repeat: acknowledge, change nothing
    if live and post.get("test_mode") == "1":
        return "OK"                    # never ship a live order on a test
    if status == "success":
        if int(total) < int(order["due"]):
            order["state"] = "review"  # underpaid: hold for a person
        else:
            order["state"], order["shipped"] = "paid", order["shipped"] + 1
    else:
        order["state"] = "failed"
    return "OK"

def signed(status, total, oid="ORD1", **extra):
    post = {"merchant_oid": oid, "status": status, "total_amount": total, **extra}
    post["hash"] = b64hmac(oid + SALT + status + total)
    return post

def refused(fn, *args):
    try:
        fn(*args)
    except ValueError:
        return True
    return False

f = iframe_fields("203.0.113.7", "ORD1", "buyer@example.com", "34.56",
                  [["Sample product", "34.56", 1]])
print(f["payment_amount"], f["paytr_token"][:16], int(19.99 * 100), kurus("19.99"))
print([refused(kurus, a) for a in ("1e400", "1e-400", "NaN", "0.99", 19.99)])

ok = signed("success", "3600", payment_amount="3456")  # installments
print(callback({**ok, "total_amount": "1"}), callback({}), orders["ORD1"]["state"])
print(callback(ok), orders["ORD1"])                    # first delivery
print(callback(ok), orders["ORD1"]["shipped"])         # repeat
```

Output:

```
3456 0RxDFeE5xAjJYgdq 1998 1999
[True, True, True, True, True]
bad hash bad hash open
OK {'due': '3456', 'state': 'paid', 'shipped': 1}
OK 1
```

Bad amounts are refused; separate tests also refused a forwarded address list and an empty order number, and gave no OK for an unknown order. A tampered or empty callback gets bad hash and leaves the order open. The first valid delivery ships once; the repeat ships nothing. The token matched PayTR's Python and Node sample formulas on the same inputs. Calling get-token and receiving a real callback need a merchant store, so neither ran. In production, use a database transaction with a unique key on merchant_oid instead of this dictionary.

## PayTR callback: plain OK, retries and duplicates

Only the callback confirms a payment, and until it answers OK the payment counts as unfinished.

- **The redirect is not a result.** The docs warn that payment may not yet be approved when the buyer reaches merchant_ok_url, and that page receives no data.
- **Answer OK and nothing else.** No HTML before or after it, no session check and no access restriction on the URL.
- **Retries.** Without OK, PayTR tries again after a minute. Step 2 states no maximum, but the FAQ describes stopped notifications (*Bildirim Durdu*) that you restart in the panel after fixing the cause. Act on the first notification only, matched on merchant_oid, and answer OK to the rest.
- **Without OK.** The transaction shows as In Progress (*Devam Ediyor*), the docs say the merchant will not be paid, and a refund fails with error 007.
- **What the hash covers.** In every sample, only merchant_oid, status and total_amount, though the Direct API Step 2 table also lists test_mode and payment_type as token fields. Treat the rest as unsigned: compare amounts using total_amount, which installments or other methods can push above your amount, book both values, and keep test orders in their own number range.

> **Hypothetical example.** A TRY 34.56 order paid in installments costs the buyer TRY 36.00, so the callback carries `payment_amount=3456` and `total_amount=3600`. 3600 covers the 3456 due, and the order ships once.

Failure reasons arrive in failed_reason_code, listed on the [error codes page](https://dev.paytr.com/en/hata-kodlari); 99 marks an integration error while debug_on is 0.

## PayTR test API: test mode and going live

Testing runs on your own store, which starts in test mode; a live store can send test_mode=1 for a test transaction.

1. Apply through the [merchant form](https://www.paytr.com/uye-isyeri-olun); the credentials are in the panel.
2. Complete Steps 1 and 2; the iFrame prefills test card details.
3. Pay, then find the test under Transactions by the email address you sent.
4. If it shows In Progress, open Detail to see what your callback returned.
5. Under Live Mode, confirm the integration is complete and send the request; PayTR checks the test and notifies you by SMS and email.

Locally, the FAQ says, the callback URL must be reachable from outside. Direct API test cards are on [their own page](https://dev.paytr.com/en/direkt-api/test-kart-bilgileri). The docs' [hash tool](https://dev.paytr.com/en/servis-test-araclari/hash-hesaplama) posts what you type, merchant_key and merchant_salt included, to a script on dev.paytr.com instead of computing in the browser, so enter only test values.

## Official PayTR samples to correct before copying

Several published samples fail or expose secrets as printed, so use them for the formulas only.

**Found by reading the samples and running the Python ones offline, 6 November 2026**

| Page | What the sample does | Fix |
| --- | --- | --- |
| iFrame Step 2 and Direct Step 2, Python | Compares `base64.b64encode(...)` bytes with a text field, which never match in Python 3, so a correctly signed callback got bad hash. The Direct one never returns OK | Call `.decode()`, use `hmac.compare_digest` and return OK |
| Link callback, Python | Same comparison; uncommented Turkish notes also make it a SyntaxError | Rewrite it from the PHP sample |
| iFrame Step 1, Python | Leaves the basket example inside a string, then calls `.decode()` on an empty text value, which raises AttributeError | Build the basket as bytes |
| iFrame Step 1 and Step 2, Node | Posts merchant_key and merchant_salt in the form, though neither is in the field table | Remove both; send only the token |
| Refund, Python | Omits merchant_oid from the POST, which should return error 002 | Add it, as the PHP sample does |
| Status inquiry table | Lists merchant_key and merchant_salt as request fields, while the samples send paytr_token | Follow the samples |
| iFrame Step 1, PHP | Reads the client IP from client-supplied headers first | Take it from your trusted proxy's result |

## PayTR Direct API, Link API, refunds and status

Direct API gives you the card form and its responsibilities; the other services are narrower.

- **Direct API.** [PayTR's overview](https://dev.paytr.com/en/direkt-api) says it is enabled per store on approval, and that the whole flow, security included, is the store owner's responsibility. Your page hosts the card fields and must post only to `https://www.paytr.com/odeme`, never to your own server. That keeps numbers off your backend, but a card form on your page still needs a [PCI DSS](https://www.pcisecuritystandards.org/standards/pci-dss/) conversation. `non_3d` and `sync_mode` need authorization on the store.
- **Installment control.** On Direct API, the FAQ makes rates and restrictions your software's job, so apply [BDDK's caps](https://www.bddk.org.tr/Mevzuat/DokumanGetir/16) yourself.
- **Card storage.** Cards are stored at PayTR, and you keep the returned utoken and ctoken.
- **Link API.** It needs approval, sends no callback for unsuccessful payments, generates the order number itself, and its callback hash starts with callback_id.
- **Refunds.** Error 009 means the refund exceeds what remains, 010 means your net balance is short, and 011 means the transaction is over a year old.
- **Status inquiry.** Use it to reconcile, and read `returns` for refunds made.

## PayTR eligibility and currencies: what remains unconfirmed

Two points stay open, so confirm both with PayTR in writing before you build.

- **Foreign companies.** The merchant form, read 6 November 2026, lists Turkish business types, an individual application and a Northern Cyprus company option, and says documents are requested after a positive result. Its field definitions include a Turkish ID number for the authorized person and a phone number starting 05. It says nothing on other foreign companies, so eligibility is unconfirmed.
- **Currencies.** The Step 1 table lists TL or TRY, EUR, USD, GBP and RUB, but its .NET sample says to ask kurumsal@paytr.com before using USD and EUR, and the Direct API POST table lists only TL, EUR and USDD, a typo. The documents conflict; ask which apply to your store.

Fees are contractual and not covered here. Budget drivers are in [fintech app development cost](https://fincodelabs.com/en/blog/fintech-app-development-cost). For integration work in general, see Fincode's [white-label development](https://fincodelabs.com/en/services/white-label-development) page; naming PayTR here does not mean Fincode integrates it or has built on it.

## Frequently asked questions

### How is the PayTR iFrame token calculated?

Concatenate merchant_id, user_ip, merchant_oid, email, payment_amount, user_basket, no_installment, max_installment, currency, test_mode and merchant_salt. Then take the base64 of HMAC-SHA256 with merchant_key.

### Does PayTR have a sandbox?

The pages read show no separate sandbox host. A store starts in test mode, a live store can send test_mode=1, and the iFrame prefills test cards.

### Why do I get a bad hash on PayTR callbacks in Python?

The published Python sample compares bytes with text, which never match in Python 3. Decode the base64 value and use hmac.compare_digest.

### Can a foreign company use PayTR?

The pages read do not say. The merchant form lists Turkish business types and a Northern Cyprus company option, so ask PayTR in writing.

## Sources

- [PayTR Developer Portal: iFrame API Step 1](https://dev.paytr.com/en/iframe-api/iframe-api-1-adim)
- [PayTR Developer Portal: iFrame API Step 2](https://dev.paytr.com/en/iframe-api/iframe-api-2-adim)
- [PayTR Developer Portal: iFrame integration and live mode steps (Turkish)](https://dev.paytr.com/en/home/iframe-api-entegrasyon-sureci)
- [PayTR Developer Portal: Direct API Step 1](https://dev.paytr.com/en/direkt-api/direkt-api-1-adim)
- [PayTR Developer Portal: Direct API Step 2](https://dev.paytr.com/en/direkt-api/direkt-api-2-adim)
- [PayTR Developer Portal: Card Storage API](https://dev.paytr.com/en/direkt-api/kart-saklama-api)
- [PayTR Developer Portal: Link API Create](https://dev.paytr.com/en/link-api/link-api-create)
- [PayTR Developer Portal: Link API Callback](https://dev.paytr.com/en/link-api/linkle-api-callback)
- [PayTR Developer Portal: Status Inquiry API](https://dev.paytr.com/en/durum-sorgu)
- [PayTR Developer Portal: Refund API](https://dev.paytr.com/en/iade-api)
- [PayTR Developer Portal: Error codes (Turkish)](https://dev.paytr.com/en/hata-kodlari)
- [PayTR Developer Portal: Frequently Asked Questions](https://dev.paytr.com/en/sikca-sorulan-sorular)
- [TCMB: E-money institutions register (Turkish)](https://www.tcmb.gov.tr/wps/wcm/connect/TR/TCMB+TR/Main+Menu/Temel+Faaliyetler/Odeme+Hizmetleri/Elektronik+Para+Kuruluslari)
- [BDDK: Credit card installment limits and bans (Turkish)](https://www.bddk.org.tr/Mevzuat/DokumanGetir/16)
- [PayTR: Home page license statement (Turkish)](https://www.paytr.com/)

> This guide is general information about software and markets, not investment, legal or tax advice. Automated trading can lose money, past or backtested performance does not guarantee future results, and the rules that apply depend on your jurisdiction and your exchange's terms. Sources, checks and corrections: see the [editorial policy](https://fincodelabs.com/en/editorial-policy).

---

Published by Fincode (Fincode Yazılım ve Teknoloji A.Ş.), a software company in Istanbul, Turkey: https://fincodelabs.com/en/blog/paytr-api-integration

Cite as: Fincode, "PayTR API: iFrame token, callback and test mode in Python", updated November 6, 2026, https://fincodelabs.com/en/blog/paytr-api-integration
