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; 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.
| 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 (opens in a new tab) says the Main User and Technical User can access in the merchant panel. Keep the last two on your server, as API key security explains.
PayTR iFrame API: token order and field traps
A token breaks when a signed field differs by one byte from what you post.
| 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 |
| 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.
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"]) # repeatOutput:
3456 0RxDFeE5xAjJYgdq 1998 1999
[True, True, True, True, True]
bad hash bad hash open
OK {'due': '3456', 'state': 'paid', 'shipped': 1}
OK 1Bad 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 (opens in a new tab); 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.
- Apply through the merchant form (opens in a new tab); the credentials are in the panel.
- Complete Steps 1 and 2; the iFrame prefills test card details.
- Pay, then find the test under Transactions by the email address you sent.
- If it shows In Progress, open Detail to see what your callback returned.
- 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 (opens in a new tab). The docs' hash tool (opens in a new tab) 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.
| 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 (opens in a new tab) 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 (opens in a new tab) conversation.non_3dandsync_modeneed authorization on the store. - Installment control. On Direct API, the FAQ makes rates and restrictions your software's job, so apply BDDK's caps (opens in a new tab) 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
returnsfor 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. For integration work in general, see Fincode's 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 (opens in a new tab)
- PayTR Developer Portal: iFrame API Step 2 (opens in a new tab)
- PayTR Developer Portal: iFrame integration and live mode steps (Turkish) (opens in a new tab)
- PayTR Developer Portal: Direct API Step 1 (opens in a new tab)
- PayTR Developer Portal: Direct API Step 2 (opens in a new tab)
- PayTR Developer Portal: Card Storage API (opens in a new tab)
- PayTR Developer Portal: Link API Create (opens in a new tab)
- PayTR Developer Portal: Link API Callback (opens in a new tab)
- PayTR Developer Portal: Status Inquiry API (opens in a new tab)
- PayTR Developer Portal: Refund API (opens in a new tab)
- PayTR Developer Portal: Error codes (Turkish) (opens in a new tab)
- PayTR Developer Portal: Frequently Asked Questions (opens in a new tab)
- TCMB: E-money institutions register (Turkish) (opens in a new tab)
- BDDK: Credit card installment limits and bans (Turkish) (opens in a new tab)
- PayTR: Home page license statement (Turkish) (opens in a new tab)
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.