קבלו תשלומים בקריפטו
Paysell מסלקת TON ו-USDT ברשת TON. אתם יוצרים חשבונית, אנחנו נותנים לכם קישור, ואתם מקבלים callback חתום ברגע שהכסף מאושר בבלוקצ'יין ונזקף ליתרה שלכם.
סקירה כללית#
מה Paysell עושה, ומה לא.
Paysell הוא מעבד תשלומים, לא ארנק. אתם לעולם לא מטפלים במפתחות פרטיים, לא עוקבים אחרי הבלוקצ'יין, ולא מחליטים מתי עסקה היא סופית — זה החלק שאנחנו לוקחים על עצמנו.
כל חשבונית מקבלת כתובת קבלה משלה. כשקונה משלם אותה, אנחנו מחכים שהרשת תאשר את ההעברה, מנכים את העמלה שלנו, וזוקפים את השאר ליתרה שלכם. אתם מושכים לכל כתובת שתרצו.
איך תשלום עובד#
שישה שלבים, רובם שלנו.
שישה שלבים, רובם שלנו:
- 1
הלקוח שלכם לוחץ על תשלום
השרת שלכם קורא ל-API שלנו עם הסכום וההפניה שלכם להזמנה.
- 2
אנחנו מספקים כתובת
כתובת קבלה חדשה נלקחת ממאגר שנוצר מראש ומקושרת לחשבונית הזו. כתובת אחת שייכת בדיוק לחשבונית פתוחה אחת, וכך תשלום מותאם אליה.
- 3
הלקוח שולח את המטבעות
הוא סורק את קוד ה-QR או מעתיק את הכתובת. שלחו אותו ל-
payment_urlשאנחנו מחזירים, והעמוד כבר מטפל בהכול עבורכם — סכום, כתובת, QR, ספירה לאחור, סטטוס בזמן אמת. - 4
אנחנו מזהים את ההעברה
שני מקורות עצמאיים של נתוני בלוקצ'יין נשאלים, והתשובות שלהם מושוות. אם הן לא תואמות, אנחנו עוצרים במקום לבחור את התשובה הנוחה יותר.
- 5
אנחנו מחכים לסופיות
הכללה במאסטרצ'יין בתוספת שלושה בלוקים מעליה. בערך חמש עשרה שניות — תשלום שנראה מסודר ואז נעלם יהיה ההפסד שלכם, ולכן אנחנו לא לוקחים את הסיכון הזה.
- 6
נזקף, ואתם מקבלים הודעה
העמלה מנוכה, השאר מגיע ליתרה שלכם, ו-webhook חתום נשלח לשרת שלכם עם ה-
order_idשלכם.
מהתשלום ועד ה-callback: בערך דקה — כחמש עשרה שניות של אישורי רשת, השאר הוא הסריקה שלנו של כתובות מפוקחות.
לאן הכסף הולך#
העמלה, ועל מה היא מחושבת.
העמלה היא 0.2%, קבועה לחנות שלכם מרגע הרישום שלה. אם התעריף הסטנדרטי משתנה מאוחר יותר, שלכם לא משתנה — הוא כתוב בכל חשבונית כמספר, לא כהפניה להגדרה.
העמלה נלקחת ממה שבאמת מגיע, לא ממה שהחשבונית ביקשה. הפיקו חשבונית על 5 USDT וקיבלו 20, העמלה מחושבת על 20. אם שולם פחות, היא מחושבת על מה שהגיע.
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תשלום עודף נזקף במלואו — אנחנו לא שומרים את ההפרש. תשלום חסר משאיר את החשבונית פתוחה כדי שהקונה יוכל להשלים לאותה כתובת.
התחלה מהירה#
חמש דקות עד החשבונית הראשונה שלכם.
חמישה שלבים. שניים מהם לחיצות באזור החשבון שלכם, אחד הוא בקשה אחת מהשרת שלכם, ושני האחרונים קורים מעצמם.
- 1
צרו חנות
באזור החשבון שלכם. היא מתחילה לקבל תשלומים מיד — בלי לחכות לבדיקה. האימות מתבצע בשקט ברקע ומגביל רק משיכות, לא תשלומים נכנסים.
- 2
הנפיקו מפתח API
החנות שלכם ← מפתחות API ← מפתח חדש. המפתח וסוד הוובהוק מוצגים פעם אחת ולעולם לא שוב. שמרו עליהם כמו שהייתם שומרים סיסמת מסד נתונים, ולעולם אל תשלחו אותם לדפדפן.
- 3
צרו חשבונית
בקשה אחת מהשרת שלכם, קישור אחד בחזרה. ארבעת קטעי הקוד שלמטה שולחים בדיוק את אותו הדבר.
- 4
הפנו את הקונה ל-
payment_urlזו כל הקופה — סכום, כתובת, קוד QR, ספירה לאחור, סטטוס בזמן אמת — ואין מה לבנות. ראו קופה כדי לראות מה הקונה באמת רואה.
- 5
המתינו לוובהוק
ברגע שהכסף מאושר בשרשרת ונזקף, אנחנו שולחים POST של אירוע
payment.creditedחתום לשרת שלכם. אמתו את החתימה, ורק אז סמנו את ההזמנה כמשולמת — אבל רק כאשרdata.statusהואpaidאוoverpaid. ראו Webhooks.
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"
}'הפנו את הקונה ל-payment_url בתשובה. סיימתם — השאר יגיע כ-webhook.
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.
אימות#
מפתח ה-API שלכם, ואיך הוא משמש.
כל בקשה נושאת את המפתח שלכם בכותרת Authorization:
Authorization: Bearer sk_live_IH4SNdnYsv-yabTuPCVln4vOvvGoEyxAכל מפתח שמונפק כאן מתחיל ב-sk_live_. הקידומת sk_test_ קיימת רק בפריסה מול רשת בדיקה, ופריסה כזו אינה מוצעת — ראו בדיקות. אנחנו שומרים hash חד-כיווני, לא את המפתח עצמו, כך שאף אחד, כולל אנחנו, לא יכול להציג אותו לכם שוב. איבדתם אותו? הנפיקו חדש ובטלו את הישן.
החנות נגזרת מהמפתח, ולכן אף בקשה לעולם לא נושאת מזהה חנות. מפתח יכול לפעול רק על החנות שלו.
בכתובת יש גרסה: /api/merchant/v1/…. בתוך גרסה אנחנו רק מוסיפים שדות — שום דבר לא משנה שם ולא משנה משמעות בשקט. שינוי שישבור לכם את הקוד מקבל תחילית חדשה, /v2, ו-/v1 ממשיך לעבוד לתקופה שהוכרזה.
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.
יצירת חשבונית#
POST /api/merchant/v1/invoices
/api/merchant/v1/invoicesגוף הבקשה
| שדה | סוג | חובה | תיאור |
|---|---|---|---|
| asset | string | כן | TON או USDT_TON. |
| amount | string | כן | יחידות רגילות של המטבע, כמחרוזת: "5" הוא 5 USDT. לא יותר ספרות אחרי הנקודה ממה שיש למטבע. ראו סכומים. |
| order_id | string | לא | ההפניה שלכם, עד 200 תווים. חוזרת בכל וובהוק — כך אתם מתאימים תשלום להזמנה. |
| description | string | לא | עד 1000 תווים. מוצג לקונה בעמוד התשלום. |
| ttl_minutes | number | לא | כמה זמן החשבונית נשארת ניתנת לתשלום, בדקות. 1–1440; אם תשמיטו אותו, חלה ברירת המחדל — שעתיים כיום. |
| idempotency_key | string | לא | עד 200 תווים. שלחו את אותו ערך בניסיון חוזר ותקבלו את אותה חשבונית במקום שנייה. זהו שדה בגוף הבקשה, ולא הכותרת Idempotency-Key — הכותרת הזאת אינה נקראת כאן. |
תשובה · 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"
}איך להשתמש בזה בהזמנה שלכם
| שדה | מה לעשות איתו |
|---|---|
| invoice_id | שמרו אותו יחד עם ההזמנה שלכם. זה מה שמזהה את התשלום בכל מקום אחר. |
| payment_url | הפנו את הקונה לכאן. אין צורך לבנות שום דבר נוסף. |
| address | רק אם אתם בונים קופה משלכם. הציגו אותה בדיוק כפי שהתקבלה — ראו האזהרה למטה. |
| amount | הסכום ביחידות רגילות, בדיוק כפי ששלחתם. את זה מציגים. |
| amount_minor | אותו סכום כמספר שלם ביחידה הקטנה ביותר. איתו מחשבים. |
| expires_at | הציגו ספירה לאחור. אחרי שהיא חולפת, הכתובת מפסיקה להיות מפוקחת עבור החשבונית הזו. |
| status | כאן תמיד pending. שינויים אמיתיים מגיעים דרך webhook. |
UQ… ברשת הראשית, 0Q… ברשת הבדיקה). המרה, ייפוי, או החלפה בקידוד אחר של אותה כתובת יגרמו למטבעות שנשלחו לארנק שטרם נפרס לחזור לשולח.קריאת חשבונית#
GET /api/merchant/v1/invoices/{invoice_id}
/api/merchant/v1/invoices/{invoice_id}אותה צורה כמו למעלה, כאשר status, paid ו-paid_minor משקפים את ההווה: paid הוא כמה הגיע ביחידות רגילות, ו-paid_minor אותו סכום כמספר שלם ביחידה הקטנה ביותר. שימושי כגיבוי כאשר וובהוק הוחמץ, או בעמוד תודה.
בקשו אותה לכל היותר כל כמה שניות, והתייחסו ל-webhooks כערוץ העיקרי. חשבוניות ששייכות לחנות אחרת עונות 404 — לא 403, כך שלא ניתן לבדוק קיום של מזהה.
ביטול חשבונית#
POST /api/merchant/v1/invoices/{invoice_id}/cancel
/api/merchant/v1/invoices/{invoice_id}/cancelסוגר חשבונית שעדיין פתוחה — pending או underpaid — ומשחרר את כתובתה. השתמשו בזה כשהלקוח נוטש את הקופה: כתובות הן משאב מוגבל, והחזרתן שומרת על המאגר בריא.
חשבונית שאינה פתוחה עוד עונה 409. ביטול חשבונית underpaid אינו מחזיר מטבעות לאף אחד: כסף שכבר נזקף נשאר ביתרה שלכם, וכל מה שנסגר הוא האפשרות לקבל השלמה.
Webhooks#
מה מגיע, ואיך לאמת את זה.
הגדירו כתובת וובהוק בעת יצירת המפתח. אנחנו שולחים לשם POST כשתשלום נזקף — וגם כשהפקדה שהוחזקה לבדיקה נוספת נדחית. כל משלוח חתום, ואנחנו ממשיכים לנסות שוב במשך כיום וחצי עד שתענו 2xx. שחררו את הסחורה לפי status: paid או overpaid — ולא בגלל עצם הגעת הקריאה.
אירועים
| אירוע | מתי | מה יש בגוף |
|---|---|---|
| payment.credited | ההעברה אושרה בשרשרת, העמלה שלנו נגבתה, והשאר ביתרה שלכם. | השדות המפורטים למטה. |
| payment.rejected | הפקדה שהוחזקה לבדיקה נוספת (ראו מדריך סטטוסים) נדחתה. הכסף לא יגיע ליתרה שלכם. | invoice_id, order_id, asset, amount, tx_hash ו-reason. אל תשחררו את הסחורה; אם החשבונית כבר הייתה paid מהעברה מוקדמת יותר, האירוע נוגע להפקדה העודפת ולא לאותו תשלום. |
מה מגיע
{
"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"
}
}מיפוי שדות
| שדה | משמעות |
|---|---|
| event_id | ייחודי לכל אירוע; מופיע גם בכותרת X-Paysell-Event-Id. שמרו אותו והתעלמו מחזרות — ראו למטה. |
| data.order_id | ההפניה שלכם. חפשו את ההזמנה שלכם לפיה. |
| data.amount | כמה הקונה שלח בהעברה הזאת, ביחידה הקטנה ביותר — בניגוד ל-API, שמקבל יחידות רגילות. |
| data.fee | כמה גבינו, ביחידה הקטנה ביותר. |
| data.credited | כמה נכנס ליתרה שלכם: amount − fee, ביחידה הקטנה ביותר. |
| data.paid_minor | סך הכול שהתקבל על החשבונית הזאת עד כה, ביחידה הקטנה ביותר. זה השדה שחשוב במצב underpaid: הסטטוס אומר שהגיע פחות, וזה אומר בכמה פחות. |
| data.asset | המטבע שהגיע בפועל. לא בהכרח המטבע של החשבונית. |
| data.asset_mismatch | קיים, ובערך true, רק כשהמטבע שהגיע אינו המטבע של החשבונית. הכסף נזקף לכם, אך החשבונית נשארת לא משולמת ו-status לעולם לא יהיה paid. |
| data.invoice_asset | מגיע יחד עם asset_mismatch: המטבע שהחשבונית באמת מבקשת. |
| data.status | הסטטוס של החשבונית עכשיו: pending, underpaid, paid, overpaid או expired. השוו למה שציפיתם. |
| data.tx_hash | העסקה בבלוקצ'יין, לרישומים שלכם ולתמיכה. |
כותרות בכל משלוח
X-Paysell-Event: payment.credited
X-Paysell-Event-Id: 99f74f58-efbb-4af1-b0a3-76b0073f9e6b
X-Paysell-Timestamp: 1789000000
X-Paysell-Signature: sha256=6f1c0e6a…| כותרת | משמעות |
|---|---|
| X-Paysell-Event | סוג האירוע: payment.credited או payment.rejected. |
| X-Paysell-Event-Id | ייחודי לכל אירוע. זה הערך שלפיו מסננים כפילויות. |
| X-Paysell-Timestamp | מתי חתמנו, בשניות יוניקס. הוא חלק מהמחרוזת החתומה. |
| X-Paysell-Signature | sha256= ואחריו ה-HMAC בהקסדצימלי. ראו למטה. |
אימות החתימה
כל בקשה חתומה בסוד הוובהוק שמוצג פעם אחת בלבד, כשיצרתם את המפתח. החתימה היא HMAC-SHA256(secret, "{timestamp}.{raw_body}") — חותמת הזמן מ-X-Paysell-Timestamp, נקודה ממשית, ואז בייטים של הגוף. בדקו אותה לפני שאתם פועלים: בלי זה, כל מי שילמד את הכתובת שלכם יוכל להעביר לכם הזמנה משולמת.
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)
}חתמו על בייטים גולמיים של הגוף, בדיוק כפי שהתקבלו. אם תפרסרו את ה-JSON ותסדרו אותו מחדש, הבייטים ישתנו — סדר מפתחות, רווחים — והחתימה לא תתאים. השוו בזמן קבוע (hmac.compare_digest, crypto.timingSafeEqual): השוואת == רגילה חוזרת מהר יותר כשהבייט הראשון שגוי, וההפרש הזה מספיק כדי לנחש חתימה בייט אחר בייט.
חלון חותמת הזמן
דחו כל בקשה שחותמת הזמן שלה רחוקה ביותר מחמש דקות מהשעון שלכם, לכל אחד משני הכיוונים. חותמת הזמן נמצאת בתוך המחרוזת החתומה בדיוק כדי שלא ניתן יהיה לערוך אותה בלי לשבור את החתימה; החלון הוא מה שהופך את זה להגנה. בלעדיו, בקשה שנלכדה פעם אחת נשארת תקפה לנצח וניתן לשדר אותה מחדש בכל רגע — החתימה לבדה לעולם אינה פגה. שמרו על שעון השרת שלכם מסונכרן ב-NTP, אחרת הבדיקה הזאת תתחיל לדחות משלוחים תקינים.
כפילויות
אותו אירוע יכול להגיע יותר מפעם אחת. זה לא באג: אנחנו מנסים שוב עד שתענו 2xx, ומשלוח שהצליח אך התשובה שלו מעולם לא הגיעה אלינו נשלח שוב. תעדו את X-Paysell-Event-Id (הוא מגיע גם כ-event_id בגוף) וודאו שההגעה השנייה לא תעשה כלום.
ניסיונות חוזרים
הניסיון הראשון יוצא ברגע שהתשלום נזקף. אם הוא נכשל — פסק זמן, סירוב חיבור, שגיאת TLS, הפניה, או כל סטטוס שאינו 2xx — אנחנו מנסים שוב לפי לוח זמנים קבוע:
דקה אחת → 5 דקות → 15 דקות → שעה → 6 שעות → 24 שעותשבעה ניסיונות בסך הכול, פרוסים על פני כ-31 שעות. המוקדמים צפופים זה לזה כי הסיבה הרגילה היא מקבל שהיה באתחול מחדש וכבר חזר; המאוחרים דלילים כי להטיח בקשות בשרת שנפל לפני יממה לא עוזר לאף אחד.
אחרי הניסיון האחרון המשלוח מסומן dropped ואנחנו עוצרים מעצמנו. הוא לא אבוד: שורת התשלום באזור החשבון שלכם מציגה את המצב, את מספר הניסיונות ואת סוג השגיאה, עם כפתור שליחה מחדש שמתחיל סבב חדש של כל שבעת הניסיונות. המוצא הנוסף שלכם הוא GET /api/merchant/v1/invoices/{invoice_id} — החשבונית תמיד יודעת את הסטטוס של עצמה.
איך חייבת להיראות כתובת וובהוק
הכתובת נבדקת כשאתם שומרים אותה, ושוב לפני כל משלוח בודד. כתובת שנכשלת בבדיקה נענית בשמירה ב-422 עם code: "webhook_url_rejected", ומסמנת את המשלוח כ-failed — בלי ניסיונות חוזרים — אם היא מתחילה להיכשל מאוחר יותר. הכללים:
- `https://` בלבד, ופורט 443. וובהוק נושא פרטי תשלום; ב-http פשוט הם קריאים לכל מי שנמצא בדרך.
- שם דומיין, לא כתובת IP. ממילא אתם צריכים תעודה, ותעודות לא מונפקות ל-IP חשוף.
- בלי `localhost`, ובלי שם
.local,.internal,.corp,.lanאו.test— השרתים שלנו לא יכולים להגיע לרשת שלכם, ושם שנפתר בתוך הרשת שלנו הוא בדיוק מה שאסור לנו לקרוא אליו. - בלי פרטי הזדהות בכתובת (
https://user:pass@…). אם אתם צריכים אסימון משלכם, שימו אותו בנתיב או בפרמטר שאילתה. - כל כתובת שהשם נפתר אליה חייבת להיות ציבורית — גם A וגם AAAA. טווחים פרטיים, לולאה מקומית, link-local ו-CGNAT נדחים, והבדיקה חוזרת לפני כל משלוח, כך שגם הפניית הרשומה ל-
127.0.0.1בהמשך לא תעבוד. - הפניה היא כישלון, לא קפיצה נוספת. אנחנו לא עוקבים אחריהן: את הכתובת שנתתם לנו בדקנו, ואת זו שבכותרת
Locationלא.
ענו מהר
כל 2xx מספיק, תוך עשר שניות — זה כל פסק הזמן שלנו, כולל יצירת החיבור. ענו קודם, ואת העבודה האיטית עשו אחר כך; נקודת קצה שממתינה למסד הנתונים שלה לפני שהיא עונה תתועד בסופו של דבר כפסק זמן ותקבל ניסיון חוזר, ואתם תעבדו את אותו אירוע פעמיים. כל דבר אחר — 4xx, 5xx, הפניה, תקיעה — נחשב לניסיון שנכשל וחוזר ללוח הזמנים שלמעלה.
משלוח, בכנות
מה שמובטח הוא מנגנון המשלוח: שבעה ניסיונות על פני כ-31 שעות, שליחה חוזרת ידנית מאזור החשבון שלכם, ונקודת קצה של חשבונית שתמיד יודעת את הסטטוס האמיתי. בנו את הזרימה כך שוובהוק שלא הגיע לעולם לא יעלה לכם כלום — קראו את החשבונית בדף התודה, או בצעו התאמה של חשבוניות פתוחות פעם בשעה. וובהוקים הם המסלול המהיר, לא המסלול היחיד.
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.
מדריך סטטוסים#
כל סטטוסי החשבוניות והתשלומים, מוסברים.
חשבונית
| סטטוס | משמעות | מה לעשות |
|---|---|---|
| pending | ממתין לתשלום. | השאירו את ההזמנה פתוחה. |
| paid | שולם במלואו. | ספקו את הסחורה. |
| overpaid | הגיע יותר ממה שהתבקש. העודף נזקף לכם במלואו. | ספקו את הסחורה; החזירו את ההפרש אם תרצו. |
| underpaid | הגיע פחות ממה שהתבקש. החשבונית נשארת פתוחה ושומרת על הכתובת שלה: הקונה יכול להשלים לאותו מקום, ו-paid_minor אומר כמה כבר נכנס. היא ניתנת לתשלום למשך שארית חייה ועוד 24 שעות חסד אחרי expires_at. | המתינו להשלמה, או הגיעו להסדר עם הלקוח. אל תספקו את הסחורה — החשבונית אינה משולמת. |
| expired | החלון נסגר, כולל תקופת החסד. ייתכן שעדיין יש עליה כסף: כל מה שהגיע נשאר ביתרה שלכם, ו-paid_minor אומר כמה. | הציעו חשבונית חדשה. אל תקבלו תשלום לכתובת הישנה: ברגע שחשבונית פגה, הכתובת חוזרת למאגר, והעברה שמגיעה באיחור רב היא מקרה לתמיכה ולא זקיפה אוטומטית. בדקו את paid_minor לפני שתאמרו ללקוח שלא התקבל כלום. |
| cancelled | בוטל על ידכם. הכתובת מוחזרת למאגר. | כלום. |
תשלום
גלוי באזור החשבון שלכם; שימושי בתמיכה בלקוח באמצע תשלום.
| סטטוס | משמעות |
|---|---|
| detected | נראה בבלוקצ'יין, ממתין לאישורים. |
| confirmed | הרשת אישרה אותו. זקיפה היא הבאה. |
| credited | ביתרה שלכם. זה הרגע שבו ה-webhook מופעל. |
| review | הוחזק לבדיקה נוספת — למשל, מטבעות שמגיעים לכתובת ללא חשבונית פתוחה. |
| rejected | לא נזקף. הסיבה תועדה. |
כשתשלום עובר ל-`review`
חלק מההפקדות מוחזקות לבדיקה נוספת במקום להיזקף מיד: סכום גדול במיוחד, מטבעות שמגיעים לכתובת ללא חשבונית פתוחה, או שני מקורות הבלוקצ'יין שאנחנו מתשאלים שחלוקים ביניהם לגבי מה שקרה. שום דבר לא אובד — הכסף ממתין להכרעה, והוובהוק נשלח מיד כשהיא מתקבלת, וזה עשוי לקרות כעבור דקות או שעות. לכן התייחסו להיעדר קריאה חוזרת על תשלום שמוצג כ-review כאל מצב רגיל ולא ככשל. אם זה קריטי להזמנה, פנו לתמיכה וציינו את ה-tx_hash.
סכומים#
החוצה יחידות רגילות, חזרה היחידות הקטנות ביותר.
שלחו סכומים ביחידות הרגילות של המטבע, כמחרוזת — "1.5" הוא אחד וחצי. לא מספר JSON ולא היחידה הקטנה ביותר.
| נכס | ספרות עשרוניות | אתם שולחים | amount_minor בתשובה |
|---|---|---|---|
| TON | 9 | "1.5" | "1500000000" |
| USDT_TON | 6 | "1.5" | "1500000" |
מחרוזת ולא מספר, כי מספרי JSON הם double לפי IEEE-754, וסכום גדול בננוטון מפסיק להיות מיוצג בהם במדויק. יותר ספרות אחרי הנקודה ממה שיש למטבע הוא 422, ולעולם לא עיגול שקט של הכסף שלכם. בוובהוקים זה הפוך: שם amount, fee ו-credited הם מספרים שלמים ביחידה הקטנה ביותר, כי את הצד ההוא קורא קוד ולא אדם.
// 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מגבלות#
מינימום, מקסימום, ומגבלות קצב.
| מגבלה | ערך | בהפרה |
|---|---|---|
| חשבונית מינימלית | 0.1 TON · 3 USDT | 422 |
| חשבונית מקסימלית | 7000 TON · 10000 USDT | 422 |
| חשבוניות לשעה, לחנות | 60 | 429 |
| חשבוניות פתוחות בו-זמנית | 20, וגדל עם כל חשבונית משולמת, עד 200 | 429 |
| אורך חיי חשבונית | דקה אחת עד 24 שעות (ברירת מחדל שעתיים) | 422 |
| בקשות API לכל מפתח | 120 לדקה | 429 + Retry-After |
המינימום אינו בירוקרטיה. העמלה שלנו היא אחוז, אבל גביית תשלום עולה סכום קבוע: להוציא USDT מכתובת קבלה פירושו לממן אותה בגז קודם, מהכיס שלנו. מתחת לכמה דולרים העמלה לא מכסה את הטיפול, וקבלת תשלום כזה משמעה לזקוף לכם כסף שלא כדאי כלכלית להזיז.
התקרה אינה מכוונת נגד סוחרים גדולים — היא מלכודת לטעות ביחידות. שלחו "5000000" במקום "5" שהתכוונתם אליו, ואחרת הייתה נוצרת לכם חשבונית על חמישה מיליון דולר: הקונה רואה סכום אבסורדי ועוזב. הזמנה אמיתית לעולם אינה מגיעה לתקרה הזאת; טעות מגיעה אליה תמיד. שתי התקרות הן הגדרות (invoice_max_ton, invoice_max_usdt) וניתן להעלות אותן עבור החנות שלכם — בקשו.
המגבלה השעתית ומגבלת החשבוניות הפתוחות מגנות שתיהן על מאגר הכתובות. כל חשבונית פתוחה תופסת כתובת קבלה, ולולאה שיצאה משליטה באתר אחד הייתה מרוקנת את המאגר עבור כולם. חנות חדשה יכולה להחזיק 20 חשבוניות פתוחות בו-זמנית; ההקצאה גדלה באחת על כל חשבונית שנגבתה בפועל, עד תקרה של 200. underpaid נחשבת פתוחה — היא עדיין מחזיקה בכתובת שלה וממתינה לשארית. ביטול חשבונית נטושה מחזיר את הכתובת שלה מיד. ניסיונות חוזרים עם אותו idempotency_key לא נספרים במגבלה השעתית.
מגבלת הבקשות היא 120 לדקה לכל מפתח API — שתי קריאות בשנייה, הרבה מעל כל זרם הזמנות אמיתי. תשובת 429 נושאת כותרת Retry-After בשניות: המתינו את הזמן הזה במקום לנסות שוב בלולאה צפופה, שרק דוחפת את החלון רחוק יותר.
שגיאות#
קודי הסטטוס שתראו בפועל.
שגיאות חוזרות כ-JSON, בשתי צורות. כל דבר שאנחנו או ליבת העיבוד מכריעים מחזיר זוג {code, message} תחת detail. גוף בקשה שנכשל בוולידציה מחזיר שם רשימה של שגיאות שדה במקום זאת. בדקו איזו מהן קיבלתם לפני שאתם קוראים את detail.code — והסתעפו לפי `code`, לעולם לא לפי `message`: הניסוח עשוי להשתנות בכל רגע, הקוד לא.
{
"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
}
]
}| סטטוס | מתי | מה לעשות |
|---|---|---|
| 401 | המפתח חסר, שגוי, או בוטל. | בדקו את הכותרת. הנפיקו מפתח חדש אם בוטל. |
| 404 | אין חשבונית כזו, או שהיא שייכת לחנות אחרת. | בדקו את המזהה. שני המקרים נענים באותו אופן במכוון, כדי שלא ניתן יהיה לגשש אחר מזהה. |
| 409 | החשבונית במצב שאוסר את זה. | קראו קודם את הסטטוס הנוכחי שלה. |
| 422 | הבקשה בנויה לא נכון, או שהסכום מחוץ לגבולות החשבונית. | ההודעה מציינת גם את הערך שנשלח וגם את המגבלה. |
| 429 | יותר מדי חשבוניות בשעה הזו, יותר מדי פתוחות בו-זמנית, או יותר מדי בקשות. | המתינו את Retry-After, ואז נסו שוב. |
| 502 | לא הצלחנו להגיע לליבת העיבוד. | נסו שוב עם אותו מפתח אידמפוטנטיות. |
קודים
הצורה שאנחנו מכריעים בה היא {"detail": {"code": …, "message": …}}. אלה הקודים שממשק ה-API לסוחרים מחזיר.
| קוד | סטטוס | משמעות |
|---|---|---|
| invalid_api_key | 401 | המפתח חסר, פגום, לא מוכר או בוטל. כל ארבעת המקרים נענים באותו אופן, כך שלא ניתן לגשש אחר מפתח. |
| not_found | 404 | אין אובייקט כזה, או שהוא שייך לחנות אחרת. |
| invalid_input | 422 | הבקשה לא עברה ולידציה בליבה — סכום שגוי, יותר מדי ספרות אחרי הנקודה, סכום מחוץ לגבולות החשבונית. |
| conflict | 409 | הפעולה סותרת את המצב הנוכחי, למשל ביטול חשבונית שאינה פתוחה עוד. |
| too_many_requests | 429 | מגבלת קצב: חשבוניות לשעה, חשבוניות פתוחות, או בקשות לדקה. Retry-After אומר כמה זמן להמתין. |
| cbc_unreachable | 502 | לא הצלחנו להגיע לליבת העיבוד. נסו שוב עם אותו idempotency_key. |
| webhook_url_rejected | 422 | רק בשמירת מפתח: כתובת הוובהוק נכשלה בבדיקות שלמעלה. detail.reason מציין איזה כלל — scheme, port, ip_literal, local_hostname, private_address, dns_error, credentials, וכן הלאה. |
502 לא אומר שהחשבונית לא נוצרה — הבקשה יכלה לעבור כשהתשובה אבדה בדרך חזרה. נסו שוב עם אותו idempotency_key ותקבלו או את החשבונית הקיימת או חדשה, לעולם לא שתיים.
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.
החזרים#
איך להחזיר כסף ללקוח.
החזרים מתבצעים דרך התמיכה, לא בקריאת API. החזר הוא העברה חדשה לכתובת שאדם מסר, ומעבד תשלומים ששולח כסף בחזרה אוטומטית בקריאת API הוא מעבד תשלומים שאפשר לגרום לו לשלוח כסף לכתובת של תוקף. לכן זה ידני במכוון.
כדי להחזיר כסף לקונה, פתחו פנייה לתמיכה מאזור החשבון שלכם עם ה-invoice_id או ה-tx_hash, הסכום, והכתובת שאליה לשלוח. אופרטור בודק את התשלום, מוציא את הכסף מהיתרה שלכם, ועונה באותה פנייה. צפו שזה ייקח יום עבודה, לא דקה.
שתי השלכות ששווה לתכנן סביבן. תשלום יתר נזקף לכם במלואו — אנחנו לא לוקחים ממנו כלום — ולכן החזרת ההפרש לקונה ששלח יותר מדי היא החלטה שלכם ועוברת באותו מסלול. וחשבונית ששולמה בחסר אינה מקרה של החזר כל עוד היא פתוחה: הכסף ביתרה שלכם, הכתובת עדיין במעקב, והקונה יכול פשוט להשלים אותה. רק אחרי תקופת החסד, כשהחשבונית עוברת ל-expired עם כסף עליה, יש החלטה לקבל.
בדיקות#
איך לבדוק את האינטגרציה לפני ההשקה.
המפתחות כאן חיים: כל מפתח שמונפק הוא מפתח sk_live_ מול ליבת הייצור ומול ה-mainnet של TON. אין סביבת בדיקות נפרדת, ויש בכך יתרון: אתם עוברים בדיוק את המסלול שבו יעברו ההזמנות האמיתיות שלכם.
לכן בדקו כפי שהייתם בודקים כל דבר שנוגע בכסף אמיתי: בסכומים קטנים. צרו חשבונית על המינימום (0.1 TON או 3 USDT), שלמו אותה מהארנק שלכם, וצפו בכל המסלול — דף התשלום, הוובהוק, בדיקת החתימה, וההזמנה שלכם שעוברת למשולמת. העמלה חלה, והמטבעות באמת זזים.
החלקים שאפשר לתרגל בלי להוציא כלום: יצירה וקריאה של חשבונית, ביטול שלה, ה-422 על סכום בנוי לא נכון, ה-401 על מפתח שגוי, ואימות החתימה שלכם עצמכם — חתמו על גוף לדוגמה עם הסוד שלכם והזינו אותו למטפל שלכם. מה שבאמת דורש תשלום אמיתי הוא רק השלב האחרון: וובהוק payment.credited של ממש.
תכננו את האינטגרציה כך שלא תהיה תלויה בארגז חול או בתשלום מדומה: את המסלול החי אפשר לאמת מהר יותר — ונאמן יותר למציאות.
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.
רשימת בדיקה לפני השקה#
עשרה דברים לבדוק לפני ההשקה.
- המפתח נמצא רק בצד השרת, לעולם לא בקוד JavaScript של דפדפן.
- חתימת הוובהוק מאומתת מול
"{timestamp}.{raw_body}", בהשוואה בזמן קבוע. - משלוחים בני יותר מחמש דקות נדחים, ושעון השרת מסונכרן ב-NTP.
X-Paysell-Event-Idחוזר לא עושה כלום בפעם השנייה.- הוובהוק עונה 2xx תוך עשר שניות; העבודה האיטית מתבצעת אחר כך.
- כתובת הוובהוק היא דומיין https:// על פורט 443, בלי הפניה לפניו.
- וובהוק שהוחמץ אינו אסון: נקודת הקצה של החשבונית נקראת בדף התודה או בסריקת התאמה.
idempotency_keyנוצר פעם אחת לכל הזמנה ומשמש שוב בניסיונות חוזרים.- סכומים יוצאים כמחרוזות ביחידות רגילות; המספרים בוובהוק נקראים כיחידות הקטנות ביותר.
- הכתובת מוצגת בדיוק כפי שהוחזרה, ללא שינוי.
overpaidו-underpaidמטופלים, לא רקpaid; ל-expiredעדיין עשוי להיותpaid_minor.- הסחורה משוחררת לפי
status: paidאוoverpaid, לעולם לא בגלל עצם הגעת הקריאה. 429מטופל בהמתנה ל-Retry-After, ולא בניסיון חוזר מיידי.- יתרות נקראות מאיתנו, לא נעקבות בנפרד כאמת.
משהו לא ברור?
אם העמוד הזה לא ענה על השאלה שלכם, זהו פער בתיעוד שכדאי לספר לנו עליו. כתבו לנו מאזור החשבון שלכם ואנחנו נתקן את העמוד, לא רק את התשובה.