Paysell

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.

Bakiyeler bizde tutulur ve tek gerçek kaynak (source of truth) onlardır. Onları gösterin, ama asla ikinci bir kopyayı otoriter olarak tutmayın — iki sayaç er ya da geç birbirinden sapar ve o zaman hangisinin doğru olduğunu kimse bilemez.

Bir ödeme nasıl işler#

Altı adım, çoğu bize ait.

Altı adım, çoğu bize ait:

  1. 1

    Müşteriniz ödeme yap'a tıklar

    Sunucunuz, tutarı ve kendi sipariş referansınızı vererek API'mizi çağırır.

  2. 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. 3

    Müşteri parayı gönderir

    QR kodu tarar veya adresi kopyalar. Döndürdüğümüz payment_url adresine yönlendirin, sayfa sizin için hallediliyor — tutar, adres, QR, geri sayım, canlı durum.

  4. 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. 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. 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.

example
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 USDT

Fazla ö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.

Paraları bir alım adresinden çıkarmak ağ gazı gerektirir ve bunu biz öderiz — o kısım bakiyenize hiç dokunmaz. Kendi adresinize para çekmek ise başka bir şeydir: kendi ücreti vardır, istediğiniz tutardan düşülür ve kesin rakamlar ücret tarifesinde yer alı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. 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. 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. 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. 4

    Alıcıyı payment_url adresine 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. 5

    Webhook'u bekleyin

    Para zincirde onaylanıp bakiyenize geçtiğinde, sunucunuza imzalı bir payment.credited olayı POST ederiz. İmzayı doğrulayın, sonra siparişi ödendi olarak işaretleyin — ama yalnızca data.status değeri paid veya overpaid ise. 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

Kimlik doğrulama#

API anahtarınız ve nasıl kullanıldığı.

Her istek, anahtarınızı Authorization başlığında taşır:

http
Authorization: Bearer sk_live_IH4SNdnYsv-yabTuPCVln4vOvvGoEyxA

Burada 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.

Bu anahtar sizin adınıza faturalar oluşturur. Onu sunucu tarafında tutun. Tarayıcı JavaScript'inde bulunan her şey, ne kadar iyi gizlenmiş görünürse görünsün, herkese açıktır.

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.

EndpointMethodAuthWhat it does
/invoicesPOSTAPI keyOpen an invoice and get a payment link. Details.
/invoices/{invoice_id}GETAPI keyRead one invoice's current state. Details.
/invoices/{invoice_id}/cancelPOSTAPI keyClose an invoice that is still open and free its address. Details.
/public/invoices/{invoice_id}GETnoneWhat 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

POST/api/merchant/v1/invoices

İstek gövdesi

AlanTürZorunluAçıklama
assetstringevetTON veya USDT_TON.
amountstringevetParanın normal birimleri, metin olarak: "5" 5 USDT demektir. Paranın sahip olduğundan fazla ondalık basamak olmaz. Bkz. Tutarlar.
order_idstringhayırKendi referansınız, en fazla 200 karakter. Her webhook'ta geri döner — bir ödemeyi bir siparişle bu şekilde eşleştirirsiniz.
descriptionstringhayırEn fazla 1000 karakter. Ödeme sayfasında alıcıya gösterilir.
ttl_minutesnumberhayırFaturanın ödenebilir kalma süresi, dakika cinsinden. 1–1440; belirtmezseniz varsayılan uygulanır — bugün 2 saat.
idempotency_keystringhayırEn 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

json
{
  "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

AlanOnunla ne yapılır
invoice_idSiparişinize karşı saklayın. Ödemeyi her yerde tanımlayan budur.
payment_urlAlıcıyı buraya yönlendirin. Başka bir şey oluşturmanıza gerek yok.
addressYalnızca kendi ödeme sayfanızı oluşturuyorsanız. Verildiği gibi, tam olarak gösterin — aşağıdaki uyarıya bakın.
amountTutar, gönderdiğiniz haliyle normal birimde. Gösterilecek olan budur.
amount_minorAynı tutarın en küçük birimdeki tam sayı hâli. Hesapları bununla yapın.
expires_atBir geri sayım gösterin. Süre geçtikten sonra adres bu fatura için izlenmez olur.
statusBurada her zaman pending. Gerçek değişiklikler webhook ile gelir.
Kendi sayfanızı oluşturuyorsanız, adresi döndürüldüğü tam olarak haliyle yazdırın. Sıçramaz (non-bounceable) biçimdedir (ana ağda 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}

GET/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

POST/api/merchant/v1/invoices/{invoice_id}/cancel

Hâ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

OlayNe zamanGövdede ne gelir
payment.creditedTransfer zincir üzerinde onaylandı, komisyonumuz alındı ve kalanı bakiyenizde.Aşağıda listelenen alanlar.
payment.rejectedEk 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

json
{
  "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…"
  }
}
json
{
  "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

AlanAnlamı
event_idHer 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_idReferansınız. Siparişinizi bununla arayın.
data.amountAlıcının bu transferde gönderdiği tutar, en küçük birimde — normal birim alan API'nin aksine.
data.feeBizim aldığımız, en küçük birimde.
data.creditedBakiyenize geçen: amount − fee, en küçük birimde.
data.paid_minorBu 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.assetGerçekten gelen para birimi. Faturanın istediği para birimi olmak zorunda değil.
data.asset_mismatchYalnı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_assetasset_mismatch ile birlikte gelir: faturanın gerçekte istediği para birimi.
data.statusFaturanın şu anki durumu: pending, underpaid, paid, overpaid veya expired. Beklediğinizle karşılaştırın.
data.tx_hashZincir üzerindeki işlem; kayıtlarınız ve destek için.

Her teslimatta gelen başlıklar

http
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ıkAnlamı
X-Paysell-EventOlay türü: payment.credited veya payment.rejected.
X-Paysell-Event-IdHer 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-Signaturesha256= 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:

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:

javascript
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 sa

Toplamda 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, .lan veya .test adı 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.1 adresine 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 Location başlığındaki ise edilmedi.
Doğrulama bilerek iki kez yapılır — bir kez URL'yi kaydettiğinizde, böylece bir yazım hatası sessiz bir teslimatsızlıkla değil anında yanıtlanır; bir kez de her gönderimden önce, çünkü bir alan adının sahibi onu her an iç bir adrese yönlendirebilir. Uç noktanız taşınırsa önce anahtarı güncelleyin: reddedilen bir URL hiçbir şey teslim etmez ve kuyruğa da girmez.

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.

javascript
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.

python
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 seconds

What 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`. underpaid means 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

DurumAnlamıNe yapmalı
pendingÖdeme bekliyor.Siparişi açık tutun.
paidTam 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.
expiredEk 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.
cancelledSizin 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.

DurumAnlamı
detectedZincirde görüldü, onay bekliyor.
confirmedAğ onayladı. Sırada bakiyeye geçirme var.
creditedBakiyenizde. Webhook bu anda tetiklenir.
reviewEk kontrol için tutuldu — örneğin, açık faturası olmayan bir adrese para gelmesi.
rejectedBakiyeye 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ıkOndalık basamakSiz gönderirsinizYanıttaki amount_minor
TON9"1.5""1500000000"
USDT_TON6"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.

javascript
// 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. 1500000n

Limitler#

Minimumlar, maksimumlar ve hız limitleri.

LimitDeğerİhlalde
Minimum fatura0.1 TON · 3 USDT422
Maksimum fatura7000 TON · 10000 USDT422
Mağaza başına saatte fatura60429
Aynı anda açık fatura20, ödenen her faturayla artar, 200'e kadar429
Fatura ömrü1 dakika – 24 saat (varsayılan 2 saat)422
Anahtar başına API isteğidakikada 120429 + 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.

json
{
  "detail": {
    "code": "invalid_input",
    "message": "invoice amount below the minimum: 0.010000 USDT_TON, minimum 3.000000 USDT_TON"
  }
}
json
{
  "detail": [
    {
      "type": "string_type",
      "loc": ["body", "amount"],
      "msg": "Input should be a valid string",
      "input": 5
    }
  ]
}
DurumNe zamanNe yapmalı
401Anahtar eksik, yanlış veya iptal edilmiş.Başlığı kontrol edin. İptal edildiyse anahtarı yeniden oluşturun.
404Bö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.
409Fatura 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.
429Bu 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.

KodDurumAnlamı
invalid_api_key401Anahtar eksik, bozuk, tanınmıyor veya iptal edilmiş. Dördü de aynı yanıtı verir, böylece bir anahtar yoklanamaz.
not_found404Böyle bir nesne yok veya başka bir mağazaya ait.
invalid_input422İstek çekirdekteki doğrulamayı geçemedi — hatalı bir tutar, çok fazla ondalık basamak, fatura sınırlarının dışında bir tutar.
conflict409Eylem mevcut durumla çelişiyor; örneğin artık açık olmayan bir faturayı iptal etmek.
too_many_requests429Bir hız limiti: saatlik fatura, açık fatura ya da dakikadaki istek. Retry-After ne kadar bekleneceğini söyler.
cbc_unreachable502İşleme çekirdeğine ulaşamadık. Aynı idempotency_key ile tekrar deneyin.
webhook_url_rejected422Yalnı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 is underpaid.
  • 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

InvoiceWhat 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 a 422. 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. underpaid is not paid, and a deposit in a coin the invoice did not ask for never makes it paid either. Release the goods on paid or overpaid, 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_id will 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-Key header

    This API reads idempotency_key from the request body; the header is not read at all. A retry without the field opens a second invoice for the same order.

  • Assuming detail is always an object

    It 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 reading detail.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.

İlk canlı siparişinizi gerçek test sayın: küçük bir tutar seçin, faturayı panelde açık tutun ve gerçek müşterileri oraya yönlendirmeden önce ödeme satırını ve webhook durumunu kontrol edin.

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

FileWhat it isUse it for
/llms-full.txtThe 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.txtA 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.jsonOpenAPI 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.

prompt
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.txt link. One fetch, no setup.
  • A chat window — ChatGPT, Claude, Gemini: paste the contents of /llms-full.txt into 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. Its servers entry 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-Id ikinci 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_key sipariş 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.
  • overpaid ve underpaid işleniyor, yalnızca paid değil; expired yine de paid_minor taşıyor olabilir.
  • Ürün status: paid veya overpaid ile teslim edilir, çağrının yalnızca gelmiş olmasıyla asla.
  • 429, hemen tekrar denenerek değil, Retry-After sü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.