Kripto ödemeleri kabul edin
Paysell, TON ağı üzerinde TON ve USDT ile ödeme alır. Siz bir fatura oluşturursunuz, biz size bir bağlantı veririz ve para zincir üzerinde onaylanıp bakiyenize aktarıldığında imzalı bir geri çağırma (callback) alırsınız.
Genel bakış#
Paysell'in yaptığı ve yapmadığı şeyler.
Paysell bir ödeme işlemcisidir, cüzdan değildir. Özel anahtarlarla hiç uğraşmazsınız, blockchain'i izlemezsiniz ve bir işlemin ne zaman kesinleştiğine karar vermezsiniz — bu kısmı biz üstleniriz.
Her faturanın kendine ait bir alım adresi vardır. Bir alıcı ödeme yaptığında, ağın transferi onaylamasını bekler, komisyonumuzu düşer ve kalanını bakiyenize aktarırız. İstediğiniz herhangi bir adrese para çekebilirsiniz.
Bir ödeme nasıl işler#
Altı adım, çoğu bize ait.
Altı adım, çoğu bize ait:
- 1
Müşteriniz ödeme yap'a tıklar
Sunucunuz, tutarı ve kendi sipariş referansınızı vererek API'mizi çağırır.
- 2
Bir adres veririz
Önceden oluşturulmuş bir havuzdan yeni bir alım adresi alınır ve bu faturaya bağlanır. Bir adres tam olarak bir açık faturaya aittir — bir ödemenin faturaya nasıl eşleştiği budur.
- 3
Müşteri parayı gönderir
QR kodu tarar veya adresi kopyalar. Döndürdüğümüz
payment_urladresine yönlendirin, sayfa sizin için hallediliyor — tutar, adres, QR, geri sayım, canlı durum. - 4
Transferi tespit ederiz
Blockchain verisinin iki bağımsız kaynağı sorgulanır ve yanıtları karşılaştırılır. Anlaşmazlarsa, daha uygun olanı seçmek yerine dururuz.
- 5
Kesinliği bekleriz
Masterchain'e dahil edilme, artı üzerine üç blok. Yaklaşık on beş saniye — yerleşmiş görünüp sonra kaybolan bir ödeme sizin kaybınız olurdu, bu yüzden bu riski almayız.
- 6
Bakiyeye geçti, size bildirildi
Komisyon düşülür, kalanı bakiyenize düşer ve sunucunuza
order_id'nizi taşıyan imzalı bir webhook gider.
Ödemeden geri çağırmaya kadar yaklaşık bir dakika: yaklaşık on beş saniye ağ onayları, gerisi izlenen adreslerimizi taramamız.
Para nereye gidiyor#
Komisyon ve neye göre hesaplandığı.
Komisyon %0,2'dur ve mağazanız kaydedildiği anda sabitlenir. Standart oran daha sonra değişse bile sizinki değişmez — bir ayara referans olarak değil, her faturaya bir sayı olarak yazılır.
Komisyon, faturada istenen tutardan değil, gerçekte gelen tutardan alınır. 5 USDT fatura kesin, 20 USDT gelsin, komisyon 20 üzerinden hesaplanır. Eksik ödenirse, gelen tutar üzerinden hesaplanır.
Invoice: 5.000000 USDT
Received: 20.000000 USDT (the buyer sent more)
Fee 0.2%: 0.040000 USDT (on 20, not on 5)
Credited: 19.960000 USDTFazla ödeme tam olarak bakiyeye geçer — farkı biz tutmuyoruz. Eksik ödeme, alıcının aynı adrese ek ödeme yapabilmesi için faturayı açık bırakır.
Hızlı başlangıç#
İlk faturanıza beş dakika.
Beş adım. İkisi hesap alanınızdaki birer tıklama, biri sunucunuzdan gönderilen tek bir istek, son ikisi ise kendiliğinden gerçekleşir.
- 1
Bir mağaza oluşturun
Hesap alanınızda. İnceleme beklemeden hemen ödeme kabul etmeye başlar. Doğrulama arka planda sessizce gerçekleşir ve yalnızca para çekmeyi kısıtlar, gelen ödemeleri değil.
- 2
Bir API anahtarı oluşturun
Mağazanız → API anahtarları → Yeni anahtar. Anahtar ve webhook gizli anahtarı bir kez gösterilir, bir daha asla. Onları bir veritabanı şifresi gibi saklayın ve asla bir tarayıcıya göndermeyin.
- 3
Bir fatura oluşturun
Sunucunuzdan bir istek, karşılığında bir bağlantı. Aşağıdaki dört örneğin hepsi tam olarak aynı şeyi gönderir.
- 4
Alıcıyı
payment_urladresine gönderinÖdeme akışının tamamı budur — tutar, adres, QR kodu, geri sayım, canlı durum — ve oluşturmanız gereken hiçbir şey yok. Alıcının gerçekte ne gördüğü için bkz. Ödeme sayfası.
- 5
Webhook'u bekleyin
Para zincirde onaylanıp bakiyenize geçtiğinde, sunucunuza imzalı bir
payment.creditedolayı POST ederiz. İmzayı doğrulayın, sonra siparişi ödendi olarak işaretleyin — ama yalnızcadata.statusdeğeripaidveyaoverpaidise. Bkz. Webhook'lar.
The same request, four ways
curl -X POST https://paysell.me/api/merchant/v1/invoices \
-H "Authorization: Bearer sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"asset": "USDT_TON",
"amount": "5",
"order_id": "order-1042",
"idempotency_key": "order-1042"
}'Alıcıyı yanıttaki payment_url'e yönlendirin. İşiniz bitti — gerisi webhook olarak gelir.
What to do next
- Write the webhook receiver — Webhooks and A complete receiver. Nothing else on this page matters as much: it is what turns a payment into a paid order.
- Handle
underpaidandoverpaid, not justpaid— see Status reference. - Read Typical mistakes, then walk the go-live checklist before you point real customers at it.
Kimlik doğrulama#
API anahtarınız ve nasıl kullanıldığı.
Her istek, anahtarınızı Authorization başlığında taşır:
Authorization: Bearer sk_live_IH4SNdnYsv-yabTuPCVln4vOvvGoEyxABurada verilen her anahtar sk_live_ ile başlar. sk_test_ öneki yalnızca test ağına yönlendirilmiş bir dağıtımda bulunur ve böyle bir dağıtım sunulmuyor — bkz. Test etme. Anahtarın kendisini değil, tek yönlü bir özetini (hash) saklarız; bu yüzden biz dahil kimse onu size bir daha gösteremez. Kaybettiniz mi? Yeni bir tane oluşturun ve eskisini iptal edin.
Mağaza anahtardan türetilir, bu yüzden hiçbir istek bir mağaza id'si almaz. Bir anahtar yalnızca kendi mağazası üzerinde işlem yapabilir.
Adres bir sürüm taşır: /api/merchant/v1/…. Bir sürüm içinde yalnızca alan ekleriz — hiçbir şey yeniden adlandırılmaz, hiçbir şey sessizce anlam değiştirmez. Kodunuzu bozacak bir değişiklik yeni bir önek alır, /v2, ve /v1 duyurulan bir süre boyunca çalışmaya devam eder.
Endpoints at a glance#
Four calls, three of them authenticated.
This is the whole merchant API. Balances, payouts and history are not in it — they live in your account area, where a person is looking at them.
| Endpoint | Method | Auth | What it does |
|---|---|---|---|
| /invoices | POST | API key | Open an invoice and get a payment link. Details. |
| /invoices/{invoice_id} | GET | API key | Read one invoice's current state. Details. |
| /invoices/{invoice_id}/cancel | POST | API key | Close an invoice that is still open and free its address. Details. |
| /public/invoices/{invoice_id} | GET | none | What the hosted checkout page reads. Only needed if you build your own. Details. |
Every path is relative to https://paysell.me/api/merchant/v1. There is no list endpoint and no refund endpoint — see Refunds.
Fatura oluşturma#
POST /api/merchant/v1/invoices
/api/merchant/v1/invoicesİstek gövdesi
| Alan | Tür | Zorunlu | Açıklama |
|---|---|---|---|
| asset | string | evet | TON veya USDT_TON. |
| amount | string | evet | Paranın normal birimleri, metin olarak: "5" 5 USDT demektir. Paranın sahip olduğundan fazla ondalık basamak olmaz. Bkz. Tutarlar. |
| order_id | string | hayır | Kendi referansınız, en fazla 200 karakter. Her webhook'ta geri döner — bir ödemeyi bir siparişle bu şekilde eşleştirirsiniz. |
| description | string | hayır | En fazla 1000 karakter. Ödeme sayfasında alıcıya gösterilir. |
| ttl_minutes | number | hayır | Faturanın ödenebilir kalma süresi, dakika cinsinden. 1–1440; belirtmezseniz varsayılan uygulanır — bugün 2 saat. |
| idempotency_key | string | hayır | En fazla 200 karakter. Tekrar denerken aynı değeri gönderin, ikinci bir fatura yerine aynısını geri alırsınız. Bu bir gövde alanıdır, Idempotency-Key başlığı değil — o başlık burada okunmaz. |
Yanıt · 201
{
"invoice_id": "12c22c1a-a496-4c1e-abe3-72661ef8706e",
"payment_url": "https://paysell.me/pay/12c22c1a-a496-4c1e-abe3-72661ef8706e",
"address": "UQAvDJp7QDwqRcuNQBiK2GhBt71Xh1_UMYPCzMkQAoBPmZKl",
"asset": "USDT_TON",
"amount": "5",
"amount_minor": "5000000",
"status": "pending",
"paid": "0",
"paid_minor": "0",
"order_id": "order-1042",
"description": "Pro subscription",
"expires_at": "2026-09-06T17:20:55Z",
"created_at": "2026-09-06T15:20:55Z"
}Siparişinize eşleme
| Alan | Onunla ne yapılır |
|---|---|
| invoice_id | Siparişinize karşı saklayın. Ödemeyi her yerde tanımlayan budur. |
| payment_url | Alıcıyı buraya yönlendirin. Başka bir şey oluşturmanıza gerek yok. |
| address | Yalnızca kendi ödeme sayfanızı oluşturuyorsanız. Verildiği gibi, tam olarak gösterin — aşağıdaki uyarıya bakın. |
| amount | Tutar, gönderdiğiniz haliyle normal birimde. Gösterilecek olan budur. |
| amount_minor | Aynı tutarın en küçük birimdeki tam sayı hâli. Hesapları bununla yapın. |
| expires_at | Bir geri sayım gösterin. Süre geçtikten sonra adres bu fatura için izlenmez olur. |
| status | Burada her zaman pending. Gerçek değişiklikler webhook ile gelir. |
UQ…, test ağında 0Q…). Onu dönüştürür, güzelleştirir veya aynı adresin başka bir kodlamasıyla değiştirirseniz, henüz dağıtılmamış bir cüzdana gönderilen paralar göndericiye geri sıçrar.Fatura okuma#
GET /api/merchant/v1/invoices/{invoice_id}
/api/merchant/v1/invoices/{invoice_id}Yukarıdakiyle aynı yapı; status, paid ve paid_minor mevcut durumu yansıtır: paid normal birimlerde ne kadarının geldiğini, paid_minor ise aynı değeri en küçük birimde tam sayı olarak verir. Bir webhook kaçırıldığında yedek olarak veya bir teşekkür sayfasında kullanışlıdır.
En fazla birkaç saniyede bir sorgulayın ve webhook'ları birincil kanal olarak kabul edin. Başka bir mağazaya ait faturalar 403 değil 404 yanıtı verir — böylece bir id'nin var olup olmadığı araştırılamaz.
Fatura iptali#
POST /api/merchant/v1/invoices/{invoice_id}/cancel
/api/merchant/v1/invoices/{invoice_id}/cancelHâlâ açık olan bir faturayı — pending ya da underpaid — kapatır ve adresini serbest bırakır. Müşteri ödeme akışını terk ettiğinde kullanın — adresler sınırlı bir kaynaktır ve onları geri döndürmek havuzu sağlıklı tutar.
Artık açık olmayan bir fatura 409 yanıtı verir. underpaid bir faturayı iptal etmek kimseye para geri döndürmez: hesabınıza çoktan geçmiş para bakiyenizde kalır, kapanan tek şey ek ödeme kabulüdür.
Webhook'lar#
Ne gelir ve nasıl doğrulanır.
Anahtarı oluştururken bir webhook adresi belirleyin. Bir ödeme hesabınıza geçtiğinde — ve ek kontrole alınan bir yatırma reddedildiğinde — oraya POST yaparız. Her teslimat imzalıdır ve siz 2xx yanıtlayana kadar yaklaşık bir buçuk gün boyunca denemeyi sürdürürüz. Ürünü status: paid veya overpaid ile teslim edin, çağrının yalnızca gelmiş olmasıyla değil.
Olaylar
| Olay | Ne zaman | Gövdede ne gelir |
|---|---|---|
| payment.credited | Transfer zincir üzerinde onaylandı, komisyonumuz alındı ve kalanı bakiyenizde. | Aşağıda listelenen alanlar. |
| payment.rejected | Ek kontrole alınmış bir yatırma (bkz. Durum referansı) reddedildi. Para bakiyenize ulaşmayacak. | invoice_id, order_id, asset, amount, tx_hash ve reason. Ürünü teslim etmeyin; fatura daha önceki bir transferle zaten paid olduysa bu olay o ödemeyle değil, fazladan gelen yatırmayla ilgilidir. |
Ne gelir
{
"event_id": "99f74f58-efbb-4af1-b0a3-76b0073f9e6b",
"type": "payment.credited",
"data": {
"invoice_id": "12c22c1a-a496-4c1e-abe3-72661ef8706e",
"order_id": "order-1042",
"asset": "USDT_TON",
"amount": "5000000",
"credited": "4905000",
"fee": "95000",
"status": "paid",
"paid_minor": "5000000",
"tx_hash": "97a1f0…"
}
}{
"event_id": "0a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d",
"type": "payment.rejected",
"data": {
"invoice_id": "12c22c1a-a496-4c1e-abe3-72661ef8706e",
"order_id": "order-1042",
"asset": "USDT_TON",
"amount": "5000000",
"tx_hash": "97a1f0…",
"reason": "could not be matched to any order"
}
}Alan eşlemesi
| Alan | Anlamı |
|---|---|
| event_id | Her olay için benzersizdir; ayrıca X-Paysell-Event-Id başlığında da bulunur. Saklayın ve tekrarları yok sayın — aşağıya bakın. |
| data.order_id | Referansınız. Siparişinizi bununla arayın. |
| data.amount | Alıcının bu transferde gönderdiği tutar, en küçük birimde — normal birim alan API'nin aksine. |
| data.fee | Bizim aldığımız, en küçük birimde. |
| data.credited | Bakiyenize geçen: amount − fee, en küçük birimde. |
| data.paid_minor | Bu faturaya şu ana kadar gelen toplam, en küçük birimde. underpaid durumunda asıl önemli olan alan budur: durum daha azının geldiğini söyler, bu alan ne kadar az olduğunu. |
| data.asset | Gerçekten gelen para birimi. Faturanın istediği para birimi olmak zorunda değil. |
| data.asset_mismatch | Yalnızca gelen para birimi faturanınkinden farklıysa bulunur ve true olur. Para size geçer, ancak fatura ödenmemiş kalır ve status asla paid olmaz. |
| data.invoice_asset | asset_mismatch ile birlikte gelir: faturanın gerçekte istediği para birimi. |
| data.status | Faturanın şu anki durumu: pending, underpaid, paid, overpaid veya expired. Beklediğinizle karşılaştırın. |
| data.tx_hash | Zincir üzerindeki işlem; kayıtlarınız ve destek için. |
Her teslimatta gelen başlıklar
X-Paysell-Event: payment.credited
X-Paysell-Event-Id: 99f74f58-efbb-4af1-b0a3-76b0073f9e6b
X-Paysell-Timestamp: 1789000000
X-Paysell-Signature: sha256=6f1c0e6a…| Başlık | Anlamı |
|---|---|
| X-Paysell-Event | Olay türü: payment.credited veya payment.rejected. |
| X-Paysell-Event-Id | Her olay için benzersizdir. Tekrarları ayıklamak için kullanacağınız değer budur. |
| X-Paysell-Timestamp | İmzaladığımız an, unix saniyesi cinsinden. İmzalanan metnin bir parçasıdır. |
| X-Paysell-Signature | sha256= ve ardından hex HMAC. Aşağıya bakın. |
İmzayı doğrulama
Her istek, anahtarı oluştururken bir kez gösterilen webhook gizli anahtarıyla imzalanır. İmza şudur: HMAC-SHA256(secret, "{timestamp}.{raw_body}") — zaman damgası X-Paysell-Timestamp başlığından, ardından düz bir nokta, sonra gövde baytları. Harekete geçmeden önce kontrol edin: bu olmadan, URL'nizi öğrenen herkes size ödenmiş bir sipariş verebilir.
Python:
import hmac, hashlib, time
def is_ours(body: bytes, signature: str, timestamp: str, secret: str) -> bool:
if abs(time.time() - int(timestamp)) > 300: # ±5 minutes
return False
signed = timestamp.encode() + b"." + body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
# compare_digest, not ==: a plain comparison leaks the answer through timing
return hmac.compare_digest("sha256=" + expected, signature)Node.js:
const crypto = require("node:crypto")
function isOurs(body, signature, timestamp, secret) {
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false // ±5 min
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(timestamp + ".").update(body) // body is the raw Buffer, not a parsed object
.digest("hex")
const a = Buffer.from(expected), b = Buffer.from(signature)
// timingSafeEqual throws when the lengths differ, so check that first
return a.length === b.length && crypto.timingSafeEqual(a, b)
}Ham gövde baytlarını alındığı gibi tam olarak imzalayın. JSON'ı ayrıştırıp yeniden serileştirirseniz baytlar değişir — anahtar sırası, boşluklar — ve imza eşleşmez. Sabit zamanda karşılaştırın (hmac.compare_digest, crypto.timingSafeEqual): düz bir == yanlış ilk baytta daha hızlı döner ve bu fark, bir imzayı bayt bayt tahmin etmeye yeter.
Zaman damgası penceresi
Zaman damgası kendi saatinizden beş dakikadan fazla uzak olan her şeyi reddedin, her iki yönde de. Zaman damgası tam olarak imzalanan metnin içindedir, böylece imzayı bozmadan değiştirilemez; onu korumaya dönüştüren şey ise penceredir. Pencere olmadan, bir kez yakalanan bir istek sonsuza dek geçerli kalır ve istenildiği an yeniden oynatılabilir — imza tek başına asla sona ermez. Sunucunuzun saatini NTP ile tutun, yoksa bu kontrol geçerli teslimatları reddetmeye başlar.
Tekrarlar
Aynı olay birden fazla kez gelebilir. Bu bir hata değildir: siz 2xx yanıtlayana kadar tekrar deneriz ve başarılı olup yanıtı bize ulaşmayan bir teslimat yeniden gönderilir. X-Paysell-Event-Id değerini kaydedin (gövdede event_id olarak da gelir) ve ikinci gelişin hiçbir şey yapmamasını sağlayın.
Tekrar denemeler
İlk deneme, ödeme hesabınıza geçer geçmez gider. Başarısız olursa — zaman aşımı, bağlantı reddi, TLS hatası, bir yönlendirme ya da 2xx olmayan herhangi bir durum — sabit bir takvimle tekrar deneriz:
1 dk → 5 dk → 15 dk → 1 sa → 6 sa → 24 saToplamda yedi deneme, yaklaşık 31 saate yayılır. İlk denemeler birbirine yakındır çünkü olağan sebep, yeniden başlamış ve çoktan geri dönmüş bir alıcıdır; sonrakiler seyrektir çünkü bir gündür kapalı olan bir sunucuyu dövmek kimseye fayda sağlamaz.
Son denemeden sonra teslimat dropped olarak işaretlenir ve kendiliğimizden dururuz. Kaybolmuş değildir: hesap alanınızdaki ödeme satırı durumu, deneme sayısını ve hata sınıfını gösterir; yanında yedi denemenin tamamını baştan başlatan bir Yeniden gönder düğmesi vardır. Diğer çareniz GET /api/merchant/v1/invoices/{invoice_id} — fatura kendi durumunu her zaman bilir.
Bir webhook adresi nasıl görünmeli
URL, kaydettiğinizde ve ayrıca her tek teslimattan önce yeniden kontrol edilir. Kontrolü geçemeyen bir URL, kayıt anında 422 ve code: "webhook_url_rejected" ile yanıtlanır; daha sonra bozulmaya başlarsa teslimatı failed olarak işaretler — hiç tekrar denenmeden. Kurallar:
- Yalnızca `https://` ve port 443. Bir webhook ödeme bilgilerini taşır; düz http'de bunlar yol üzerindeki herkes tarafından okunabilir.
- Bir alan adı, IP adresi değil. Zaten bir sertifikaya ihtiyacınız var ve çıplak IP'ler için sertifika verilmez.
- `localhost` yok, ayrıca
.local,.internal,.corp,.lanveya.testadı da yok — sunucularımız sizin ağınıza ulaşamaz ve bizim ağımızın içine çözümlenen bir ad, tam olarak çağırmamamız gereken şeydir. - URL içinde kimlik bilgisi yok (
https://user:pass@…). İhtiyacınız varsa kendi belirtecinizi yola veya bir sorgu parametresine koyun. - Adın çözümlendiği her adres genel olmalı — hem A hem AAAA. Özel, loopback, link-local ve CGNAT aralıkları reddedilir ve kontrol her teslimattan önce tekrarlanır; dolayısıyla kaydı sonradan
127.0.0.1adresine yönlendirmek de işe yaramaz. - Yönlendirme bir adım değil, bir başarısızlıktır. Onları takip etmeyiz: bize verdiğiniz adres kontrol edildi, bir
Locationbaşlığındaki ise edilmedi.
Hızlı yanıt verin
Herhangi bir 2xx yeterlidir, on saniye içinde — bağlantı dahil bütün zaman aşımı süremiz budur. Önce yanıtlayın, yavaş işi sonra yapın; yanıt vermeden önce kendi veritabanını bekleyen bir uç nokta er ya da geç zaman aşımı olarak kaydedilir ve tekrar denenir, siz de aynı olayı iki kez işlersiniz. Bunun dışında her şey — bir 4xx, bir 5xx, bir yönlendirme, bir takılma — başarısız deneme sayılır ve yukarıdaki takvime geri döner.
Teslimat, dürüstçe
Garanti edilen şey teslimat mekanizmasıdır: yaklaşık 31 saate yayılan yedi deneme, hesap alanınızdan elle yeniden gönderim ve gerçek durumu her zaman bilen bir fatura uç noktası. Akışı, hiç gelmeyen bir webhook size hiçbir şeye mal olmayacak biçimde kurun — teşekkür sayfanızda faturayı okuyun ya da saatte bir açık faturaları karşılaştırın. Webhook'lar hızlı yoldur, tek yol değil.
A complete receiver#
Signature, deduplication and a fast answer, end to end.
The snippets above verify one signature. This is the whole endpoint: raw body, signature check, deduplication by event_id, a fast 2xx, and the one condition that is allowed to mark an order paid.
Node.js with Express. express.raw is the part people get wrong: express.json() hands you a parsed object, and bytes you re-serialise from it are not the bytes we signed.
const express = require("express")
const crypto = require("node:crypto")
const app = express()
const SECRET = process.env.PAYSELL_WEBHOOK_SECRET
function isOurs(body, signature, timestamp) {
const sentAt = Number(timestamp)
if (!Number.isFinite(sentAt)) return false
if (Math.abs(Date.now() / 1000 - sentAt) > 300) return false // ±5 minutes
const expected = "sha256=" + crypto
.createHmac("sha256", SECRET)
.update(timestamp + ".").update(body) // raw Buffer, not a parsed object
.digest("hex")
const a = Buffer.from(expected), b = Buffer.from(signature ?? "")
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
app.post(
"/paysell/webhook",
express.raw({ type: "application/json" }), // NOT express.json()
async (req, res) => {
const signature = req.get("X-Paysell-Signature")
const timestamp = req.get("X-Paysell-Timestamp")
if (!isOurs(req.body, signature, timestamp)) return res.sendStatus(401)
const event = JSON.parse(req.body.toString("utf8"))
// Answer first: 10 seconds is the whole timeout, connection included.
res.sendStatus(200)
// Deduplicate. In real code this is a unique column, not a Set.
if (await alreadyHandled(event.event_id)) return
await remember(event.event_id)
if (event.type !== "payment.credited") return
const { order_id, status, credited, asset, tx_hash } = event.data
// The only condition that may release the goods.
if (status !== "paid" && status !== "overpaid") return
await markOrderPaid(order_id, { credited, asset, tx_hash })
}
)Python with Flask. request.get_data() is the raw body; request.form and request.json are not.
import hashlib
import hmac
import json
import os
import time
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["PAYSELL_WEBHOOK_SECRET"]
def is_ours(body: bytes, signature: str, timestamp: str) -> bool:
try:
sent_at = int(timestamp)
except (TypeError, ValueError):
return False
if abs(time.time() - sent_at) > 300: # ±5 minutes
return False
signed = timestamp.encode() + b"." + body
expected = hmac.new(SECRET.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest("sha256=" + expected, signature)
@app.post("/paysell/webhook")
def paysell_webhook():
body = request.get_data() # raw bytes, unparsed
if not is_ours(body, request.headers.get("X-Paysell-Signature", ""),
request.headers.get("X-Paysell-Timestamp", "")):
return "", 401
event = json.loads(body)
# Deduplicate. In real code this is a unique column, not a set.
if already_handled(event["event_id"]):
return "", 200 # a repeat is still a success
remember(event["event_id"])
if event["type"] == "payment.credited":
data = event["data"]
# The only condition that may release the goods.
if data["status"] in ("paid", "overpaid"):
mark_order_paid(data["order_id"], data)
return "", 200 # 2xx within 10 secondsWhat the code is doing, and why
- Verify before anything acts on the body. An unsigned request that reaches your business logic is a paid order for whoever found your URL.
- Answer 2xx first, work afterwards. Ten seconds is the whole timeout, connection included. A handler that waits for its own database gets recorded as a timeout and retried, and you process the same event twice.
- Deduplicate on `event_id` in storage that survives a restart. The in-memory set in the examples keeps them short; a real one is a unique column in your database.
- Mark the order paid only on `status: paid` or `overpaid`.
underpaidmeans part of the money arrived and the invoice is still open, and a deposit in the wrong coin never makes an invoice paid either. - Answer 2xx to a duplicate too. A repeat that gets a 4xx looks like a failure to us and comes back again on the schedule.
payment.rejected arrives at the same endpoint. It means a deposit held for an additional check was declined and the money will not be credited: release nothing, and if the invoice was already paid by an earlier transfer, this event is about the extra deposit, not about that payment.
Durum referansı#
Her fatura ve ödeme durumu, açıklamalarıyla.
Fatura
| Durum | Anlamı | Ne yapmalı |
|---|---|---|
| pending | Ödeme bekliyor. | Siparişi açık tutun. |
| paid | Tam olarak ödendi. | Ürünü teslim edin. |
| overpaid | İstenenden fazlası geldi. Fazlalık size tam olarak aktarılır. | Ürünü teslim edin; isterseniz farkı iade edin. |
| underpaid | İstenenden azı geldi. Fatura açık kalır ve adresini korur: alıcı aynı yere ek ödeme yapabilir, paid_minor ise şimdiye kadar ne kadarının geldiğini söyler. Ömrünün kalan kısmı boyunca ve expires_at sonrasındaki 24 saatlik ek süre boyunca ödenebilir kalır. | Ek ödemeyi bekleyin veya müşteriyle anlaşın. Ürünü teslim etmeyin — fatura ödenmiş değil. |
| expired | Ek süre dahil pencere kapandı. Yine de para taşıyor olabilir: gelen ne varsa bakiyenizde kaldı ve paid_minor ne kadar olduğunu söyler. | Yeni bir fatura sunun. Eski adrese ödeme kabul etmeyin: bir faturanın süresi dolduğunda adres havuza geri döner ve çok geç gelen bir transfer otomatik bir aktarım değil, bir destek konusudur. Müşteriye hiçbir şeyin gelmediğini söylemeden önce paid_minor değerini kontrol edin. |
| cancelled | Sizin tarafınızdan iptal edildi. Adres havuza geri bırakılır. | Hiçbir şey. |
Ödeme
Hesap alanınızda görünür; bir müşteriye ödeme sırasında destek verirken kullanışlıdır.
| Durum | Anlamı |
|---|---|
| detected | Zincirde görüldü, onay bekliyor. |
| confirmed | Ağ onayladı. Sırada bakiyeye geçirme var. |
| credited | Bakiyenizde. Webhook bu anda tetiklenir. |
| review | Ek kontrol için tutuldu — örneğin, açık faturası olmayan bir adrese para gelmesi. |
| rejected | Bakiyeye geçirilmedi. Nedeni kaydedilir. |
Bir ödeme `review` durumuna geçtiğinde
Bazı yatırmalar doğrudan bakiyeye geçirilmek yerine ek kontrol için tutulur: alışılmadık büyüklükte bir tutar, açık faturası olmayan bir adrese gelen para ya da sorguladığımız iki blok zinciri kaynağının ne olduğu konusunda anlaşamaması. Hiçbir şey kaybolmaz — para bir karar bekler ve karar verilir verilmez webhook tetiklenir; bu dakikalar ya da saatler sürebilir. review olarak görünen bir ödemede çağrının gelmemesini bir arıza değil, normal sayın. Bir sipariş için önemliyse desteğe yazın ve tx_hash bilgisini verin.
Tutarlar#
Dışarı normal birim, geri en küçük birim.
Tutarları paranın normal birimlerinde, metin (string) olarak gönderin — "1.5" bir buçuk demektir. JSON sayısı değil, en küçük birim de değil.
| Varlık | Ondalık basamak | Siz gönderirsiniz | Yanıttaki amount_minor |
|---|---|---|---|
| TON | 9 | "1.5" | "1500000000" |
| USDT_TON | 6 | "1.5" | "1500000" |
Sayı değil metin, çünkü JSON sayıları IEEE-754 double'dır ve nanoton cinsinden büyük bir tutar içine tam olarak sığmaz olur. Paranın sahip olduğundan fazla ondalık basamak 422 demektir, paranızın sessizce yuvarlanması değil. Webhook'larda tam tersi geçerlidir: orada amount, fee ve credited en küçük birimde tam sayıdır, çünkü o tarafı insan değil kod okur.
// Send amounts in the coin's normal units, as a string:
const amount = "1.5" // one and a half TON or USDT
// In responses, amount is that same human string; amount_minor is the
// integer in smallest units — use it for exact maths, as a string or BigInt:
BigInt(invoice.amount_minor) // e.g. 1500000nLimitler#
Minimumlar, maksimumlar ve hız limitleri.
| Limit | Değer | İhlalde |
|---|---|---|
| Minimum fatura | 0.1 TON · 3 USDT | 422 |
| Maksimum fatura | 7000 TON · 10000 USDT | 422 |
| Mağaza başına saatte fatura | 60 | 429 |
| Aynı anda açık fatura | 20, ödenen her faturayla artar, 200'e kadar | 429 |
| Fatura ömrü | 1 dakika – 24 saat (varsayılan 2 saat) | 422 |
| Anahtar başına API isteği | dakikada 120 | 429 + Retry-After |
Minimum bürokrasi değildir. Komisyonumuz yüzdeliktir, ama bir ödemeyi kabul etmenin sabit bir maliyeti vardır: USDT'yi bir alım adresinden taşımak, önce onu kendi cebimizden gaz ile fonlamak anlamına gelir. Birkaç doların altında komisyon işlemi karşılamaz ve böyle bir ödemeyi kabul etmek, taşınması ekonomik olmayan bir parayı size aktarmak anlamına gelir.
Üst sınır büyük satıcılara karşı değil — birim hatası için kurulmuş bir tuzaktır. "5000000" gönderirseniz ama kastettiğiniz "5" ise, sınır olmasa beş milyon dolarlık bir fatura çıkardı: alıcı saçma bir tutar görüp gider. Gerçek bir sipariş bu tavana asla dayanmaz; hatalı olan her zaman dayanır. Her iki tavan da birer ayardır (invoice_max_ton, invoice_max_usdt) ve mağazanız için yükseltilebilir — bize sorun.
Saatlik üst sınır da aynı anda açık fatura sınırı da adres havuzunu korur. Her açık fatura bir alım adresi tutar ve bir sitedeki kontrolsüz bir döngü, aksi halde havuzu herkes için tüketirdi. Yeni bir mağaza aynı anda 20 fatura açık tutabilir; bu hak, gerçekten tahsil ettiği her fatura için birer birer artar ve 200'de tavan yapar. underpaid açık sayılır — hâlâ adresini tutuyor, geri kalanı bekliyordur. Terk edilmiş bir faturayı iptal etmek adresini hemen geri verir. Aynı idempotency_key ile tekrarlar saatlik sınıra sayılmaz.
İstek sınırı, API anahtarı başına dakikada 120'dir — saniyede iki çağrı, gerçek hiçbir sipariş akışının çok üstünde. Bir 429, saniye cinsinden bir Retry-After başlığı taşır: sıkı bir döngüde tekrar denemek yerine o kadar bekleyin; aksi halde pencereyi yalnızca daha ileriye itersiniz.
Hatalar#
Gerçekten karşılaşacağınız durum kodları.
Hatalar JSON olarak, iki biçimde döner. Bizim ya da işleme çekirdeğinin karar verdiği her şey, detail altına bir {code, message} çifti koyar. Doğrulamayı geçemeyen bir istek gövdesi ise oraya alan hatalarından oluşan bir liste koyar. detail.code değerini okumadan önce hangisini aldığınızı kontrol edin — ve `code` üzerinden dallanın, asla `message` üzerinden değil: ifade her an değişebilir, kod değişmez.
{
"detail": {
"code": "invalid_input",
"message": "invoice amount below the minimum: 0.010000 USDT_TON, minimum 3.000000 USDT_TON"
}
}{
"detail": [
{
"type": "string_type",
"loc": ["body", "amount"],
"msg": "Input should be a valid string",
"input": 5
}
]
}| Durum | Ne zaman | Ne yapmalı |
|---|---|---|
| 401 | Anahtar eksik, yanlış veya iptal edilmiş. | Başlığı kontrol edin. İptal edildiyse anahtarı yeniden oluşturun. |
| 404 | Böyle bir fatura yok veya başka bir mağazaya ait. | id'yi kontrol edin. İki durum bilerek aynı yanıtı verir, böylece bir id yoklanamaz. |
| 409 | Fatura bunu yasaklayan bir durumda. | Önce mevcut durumunu okuyun. |
| 422 | İstek hatalı biçimde ya da tutar fatura sınırlarının dışında. | Mesaj hem gönderilen değeri hem de limiti belirtir. |
| 429 | Bu saatte çok fazla fatura, aynı anda çok fazla açık fatura ya da çok fazla istek. | Retry-After süresini bekleyin, sonra tekrar deneyin. |
| 502 | İşleme çekirdeğine ulaşamadık. | Aynı idempotency anahtarıyla tekrar deneyin. |
Kodlar
Bizim karar verdiğimiz biçim şudur: {"detail": {"code": …, "message": …}}. Bunlar, merchant API'sinin döndürdüğü kodlardır.
| Kod | Durum | Anlamı |
|---|---|---|
| invalid_api_key | 401 | Anahtar eksik, bozuk, tanınmıyor veya iptal edilmiş. Dördü de aynı yanıtı verir, böylece bir anahtar yoklanamaz. |
| not_found | 404 | Böyle bir nesne yok veya başka bir mağazaya ait. |
| invalid_input | 422 | İstek çekirdekteki doğrulamayı geçemedi — hatalı bir tutar, çok fazla ondalık basamak, fatura sınırlarının dışında bir tutar. |
| conflict | 409 | Eylem mevcut durumla çelişiyor; örneğin artık açık olmayan bir faturayı iptal etmek. |
| too_many_requests | 429 | Bir hız limiti: saatlik fatura, açık fatura ya da dakikadaki istek. Retry-After ne kadar bekleneceğini söyler. |
| cbc_unreachable | 502 | İşleme çekirdeğine ulaşamadık. Aynı idempotency_key ile tekrar deneyin. |
| webhook_url_rejected | 422 | Yalnızca bir anahtar kaydedilirken: webhook adresi yukarıdaki kontrolleri geçemedi. detail.reason hangi kuralın olduğunu belirtir — scheme, port, ip_literal, local_hostname, private_address, dns_error, credentials ve benzeri. |
Bir 502, faturanın oluşturulmadığı anlamına gelmez — istek geçmiş olabilir ama yanıt geri dönerken kaybolmuş olabilir. Aynı idempotency_key ile tekrar deneyin; ya mevcut faturayı ya da yeni bir tane alırsınız, asla ikisini birden değil.
Checkout: what the buyer sees#
The hosted payment page, and when to build your own.
payment_url points at https://paysell.me/pay/{invoice_id}. One page, no account, no login, mobile first, and nothing for you to build.
On the page
- Your shop's name, the amount and the coin, large, with the invoice's description underneath.
- A countdown to
expires_at— plus the 24-hour grace period when the invoice isunderpaid. - The invoice's short id with a copy button, so a buyer can quote it to your support.
- Wallet buttons: Tonkeeper and MyTonWallet open with the address and the amount already filled in. Other reveals a QR code and the address with a copy button.
- A warning that only this invoice's coin, on the TON network, may be sent — anything else is lost.
How the page reacts
| Invoice | What the buyer sees |
|---|---|
| pending | “Waiting for payment”, with the wallet choices and the countdown. The page re-reads the invoice every five seconds. |
| underpaid | “Received X of Y”, the exact remainder still owed, and the same address to send it to. The wallet link is prefilled with what is missing, not with the original total — otherwise the buyer would pay twice. |
| paid · overpaid | “Payment received”, and a button back to your shop if the shop has a URL. |
| expired | “Payment window closed”. If money did arrive, the amount is named with a note to contact you — silence here would send the buyer looking for their coins. |
| cancelled | “Payment cancelled”, with a link back to your shop. |
If you build your own
You gain your own branding and take on all of the above: the exact address string, the right coin, the countdown with its grace period, the underpayment case, and polling. GET /api/merchant/v1/public/invoices/{invoice_id} is the same unauthenticated read the hosted page uses — rate limited per IP, so poll it no more often than every few seconds. Print the address exactly as returned.
Typical integration mistakes#
The handful that account for most broken integrations.
None of these are exotic. Every one of them has cost somebody a day.
Sending the amount as a number
{"amount": 5}is a422. It has to be the string"5": JSON numbers are IEEE-754 doubles, and a large sum in nanotons stops being exactly representable in one.Sending the smallest unit
"5000000"for 5 USDT is a mistake in units, and the upper invoice limit exists to catch it. Smallest units are what comes back in webhooks, not what goes out in requests.Treating the webhook's arrival as payment
Read
data.status.underpaidis not paid, and a deposit in a coin the invoice did not ask for never makes it paid either. Release the goods onpaidoroverpaid, on nothing else.Not checking the timestamp
A signature on its own never expires. Without the ±5 minute window on
X-Paysell-Timestamp, a delivery captured once can be replayed at any time and will still verify.Verifying the signature over re-serialised JSON
Parse the body and serialise it again and the bytes change — key order, spacing — and the HMAC no longer matches. Sign the raw bytes exactly as received.
No deduplication
The same
event_idwill arrive twice sooner or later: we retry until you answer 2xx, and a response lost on the way back looks like a failure from here. The second arrival must do nothing.Using the
Idempotency-KeyheaderThis API reads
idempotency_keyfrom the request body; the header is not read at all. A retry without the field opens a second invoice for the same order.Assuming
detailis always an objectIt is
{code, message}for anything we or the core decide, and a list of field errors when the body itself fails validation. Check which one you got before readingdetail.code.
İadeler#
Bir müşteriye nasıl iade yaparsınız.
İadeler bir API çağrısıyla değil, destek üzerinden yapılır. İade, bir insanın verdiği adrese yapılan yeni bir transferdir; bir API çağrısıyla parayı otomatik olarak geri gönderen bir ödeme işleyicisi, saldırganın adresine para göndermeye zorlanabilecek bir ödeme işleyicisidir. Bu yüzden bilerek elle yapılır.
Bir alıcıya iade yapmak için hesap alanınızdan invoice_id veya tx_hash, tutar ve gönderilecek adres ile bir destek talebi açın. Bir operatör ödemeyi kontrol eder, parayı bakiyenizden çıkarır ve aynı talep üzerinden yanıtlar. Bunun bir dakika değil, bir iş günü süreceğini varsayın.
Etrafında tasarım yapmaya değer iki sonuç var. Fazla ödeme size tam olarak aktarılır — hiçbirini alıkoymayız — dolayısıyla fazla gönderen bir alıcıya farkı geri vermek sizin kararınızdır ve aynı yoldan gider. Ve eksik ödenmiş bir fatura, hâlâ açıkken bir iade vakası değildir: para bakiyenizdedir, adres hâlâ izlenmektedir ve alıcı basitçe ek ödeme yapabilir. Ancak ek süre bittikten sonra, fatura üzerinde parayla expired durumuna geçtiğinde verilecek bir karar doğar.
Test etme#
Entegrasyonunuzu lansmandan önce nasıl test edersiniz.
Buradaki anahtarlar canlıdır: verilen her anahtar, üretim çekirdeğine ve TON ana ağına karşı çalışan bir sk_live_ anahtarıdır. Ayrı bir test ortamı yoktur ve bunun bir avantajı var: gerçek siparişlerinizin izleyeceği yolun tam olarak aynısını denersiniz.
Bu yüzden gerçek parayla ilgili her şeyi nasıl test edecekseniz öyle test edin: küçük tutarlarla. Minimum tutarda (0.1 TON veya 3 USDT) bir fatura oluşturun, kendi cüzdanınızdan ödeyin ve tüm yolu izleyin — ödeme sayfası, webhook, imza kontrolü, siparişinizin ödendi durumuna geçmesi. Komisyon işler ve paralar gerçekten hareket eder.
Hiçbir şey harcamadan çalıştırabileceğiniz kısımlar: fatura oluşturmak ve okumak, bir faturayı iptal etmek, hatalı biçimli bir tutarda 422, yanlış anahtarda 401 ve kendi imza doğrulamanız — örnek bir gövdeyi kendi gizli anahtarınızla imzalayıp kendi işleyicinize verin. Gerçekten bir ödeme gerektiren tek şey son adımdır: gerçek bir payment.credited webhook'u.
Entegrasyonu, bir sandbox'a ya da taklit bir ödemeye bağlı olmayacak biçimde planlayın: canlı yol daha hızlı — ve daha gerçekçi — doğrulanır.
For AI agents and LLMs#
Machine-readable copies of this page, and a prompt to start from.
Everything on this page also exists in a form a model can read directly. Point your assistant at one of these instead of pasting screenshots of documentation into a chat.
The three files
| File | What it is | Use it for |
|---|---|---|
| /llms-full.txt | The whole documentation as one markdown file: endpoints, fields, statuses, limits, errors, webhooks with working verification code, the fee, the checkout page, the checklist. | Pasting into a model's context, or letting an agent fetch it. Start here. |
| /llms.txt | A short index in the llms.txt format: what Paysell is, the five rules that decide whether an integration works, and links to everything else. | Letting an agent discover the rest on its own. |
| /openapi.json | OpenAPI 3.1, generated from the running application's own models, both webhook events included. | Generating a client, or loading into anything that speaks OpenAPI. |
A prompt to start from
Copy this, replace the stack, and hand it to your assistant. It names the four things that go wrong most often, so the answer does not have to be corrected afterwards.
Read https://paysell.me/llms-full.txt and implement Paysell payments in my <stack>:
create invoices (POST /api/merchant/v1/invoices, Bearer sk_live_ key, amount as a
decimal string in normal units), redirect the buyer to payment_url, verify webhook
signatures (HMAC-SHA256 over "{timestamp}.{raw_body}", header X-Paysell-Signature,
reject anything whose X-Paysell-Timestamp is more than 300 seconds off), deduplicate
by event_id, answer 2xx within 10 seconds, and mark orders paid only on a
payment.credited event whose data.status is "paid" or "overpaid".Feeding it to a specific tool
- Agents with web access — Claude Code, Cursor, Windsurf and the like: give them the
/llms-full.txtlink. One fetch, no setup. - A chat window — ChatGPT, Claude, Gemini: paste the contents of
/llms-full.txtinto the conversation or attach it as a file. It is written to fit in one message. - OpenAPI tooling — client generators, Postman, an agent's tool schema: point it at
https://paysell.me/openapi.json. Itsserversentry already carries the production base URL, so generated calls go to the right place.
Yayına alma kontrol listesi#
Yayına almadan önce on madde.
- Anahtar yalnızca sunucu tarafında, asla tarayıcı JavaScript'inde değil.
- Webhook imzası
"{timestamp}.{raw_body}"karşısında, sabit zamanda doğrulanıyor. - Beş dakikadan eski teslimatlar reddediliyor ve sunucunun saati NTP ile tutuluyor.
- Tekrarlanan
X-Paysell-Event-Idikinci kez hiçbir şey yapmıyor. - Webhook on saniye içinde 2xx yanıtlıyor; yavaş iş bundan sonra gerçekleşiyor.
- Webhook adresi 443 portunda bir https:// alan adı ve önünde yönlendirme yok.
- Kaçırılan bir webhook atlatılabilir: fatura uç noktası teşekkür sayfasında ya da bir mutabakat taramasında okunuyor.
idempotency_keysipariş başına bir kez oluşturuluyor ve tekrarlarda yeniden kullanılıyor.- Tutarlar normal birimde metin olarak gider; webhook'taki sayılar en küçük birim olarak okunur.
- Adres, döndürüldüğü haliyle, değiştirilmeden gösteriliyor.
overpaidveunderpaidişleniyor, yalnızcapaiddeğil;expiredyine depaid_minortaşıyor olabilir.- Ürün
status: paidveyaoverpaidile teslim edilir, çağrının yalnızca gelmiş olmasıyla asla. 429, hemen tekrar denenerek değil,Retry-Aftersüresi beklenerek ele alınıyor.- Bakiyeler bizden okunuyor, ayrı bir gerçek olarak takip edilmiyor.
Bir şey belirsiz mi?
Bu sayfa sorunuzu yanıtlamadıysa, bu dokümantasyondaki bir eksikliktir ve bize bildirmeye değer. Hesap alanınızdan yazın, biz de yalnızca yanıtı değil, sayfayı da düzeltelim.