Paysell

קבלו תשלומים בקריפטו

Paysell מסלקת TON ו-USDT ברשת TON. אתם יוצרים חשבונית, אנחנו נותנים לכם קישור, ואתם מקבלים callback חתום ברגע שהכסף מאושר בבלוקצ'יין ונזקף ליתרה שלכם.

סקירה כללית#

מה Paysell עושה, ומה לא.

Paysell הוא מעבד תשלומים, לא ארנק. אתם לעולם לא מטפלים במפתחות פרטיים, לא עוקבים אחרי הבלוקצ'יין, ולא מחליטים מתי עסקה היא סופית — זה החלק שאנחנו לוקחים על עצמנו.

כל חשבונית מקבלת כתובת קבלה משלה. כשקונה משלם אותה, אנחנו מחכים שהרשת תאשר את ההעברה, מנכים את העמלה שלנו, וזוקפים את השאר ליתרה שלכם. אתם מושכים לכל כתובת שתרצו.

היתרות נמצאות אצלנו והן מקור האמת היחיד. הציגו אותן, אבל לעולם אל תשמרו עותק שני כמקור סמכות — שני מונים תמיד מסתיימים בסטייה זה מזה, ואז אף אחד לא יודע מי צודק.

איך תשלום עובד#

שישה שלבים, רובם שלנו.

שישה שלבים, רובם שלנו:

  1. 1

    הלקוח שלכם לוחץ על תשלום

    השרת שלכם קורא ל-API שלנו עם הסכום וההפניה שלכם להזמנה.

  2. 2

    אנחנו מספקים כתובת

    כתובת קבלה חדשה נלקחת ממאגר שנוצר מראש ומקושרת לחשבונית הזו. כתובת אחת שייכת בדיוק לחשבונית פתוחה אחת, וכך תשלום מותאם אליה.

  3. 3

    הלקוח שולח את המטבעות

    הוא סורק את קוד ה-QR או מעתיק את הכתובת. שלחו אותו ל-payment_url שאנחנו מחזירים, והעמוד כבר מטפל בהכול עבורכם — סכום, כתובת, QR, ספירה לאחור, סטטוס בזמן אמת.

  4. 4

    אנחנו מזהים את ההעברה

    שני מקורות עצמאיים של נתוני בלוקצ'יין נשאלים, והתשובות שלהם מושוות. אם הן לא תואמות, אנחנו עוצרים במקום לבחור את התשובה הנוחה יותר.

  5. 5

    אנחנו מחכים לסופיות

    הכללה במאסטרצ'יין בתוספת שלושה בלוקים מעליה. בערך חמש עשרה שניות — תשלום שנראה מסודר ואז נעלם יהיה ההפסד שלכם, ולכן אנחנו לא לוקחים את הסיכון הזה.

  6. 6

    נזקף, ואתם מקבלים הודעה

    העמלה מנוכה, השאר מגיע ליתרה שלכם, ו-webhook חתום נשלח לשרת שלכם עם ה-order_id שלכם.

מהתשלום ועד ה-callback: בערך דקה — כחמש עשרה שניות של אישורי רשת, השאר הוא הסריקה שלנו של כתובות מפוקחות.

לאן הכסף הולך#

העמלה, ועל מה היא מחושבת.

העמלה היא 0.2%, קבועה לחנות שלכם מרגע הרישום שלה. אם התעריף הסטנדרטי משתנה מאוחר יותר, שלכם לא משתנה — הוא כתוב בכל חשבונית כמספר, לא כהפניה להגדרה.

העמלה נלקחת ממה שבאמת מגיע, לא ממה שהחשבונית ביקשה. הפיקו חשבונית על 5 USDT וקיבלו 20, העמלה מחושבת על 20. אם שולם פחות, היא מחושבת על מה שהגיע.

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

תשלום עודף נזקף במלואו — אנחנו לא שומרים את ההפרש. תשלום חסר משאיר את החשבונית פתוחה כדי שהקונה יוכל להשלים לאותה כתובת.

הוצאת המטבעות מכתובת קבלה עולה גז רשת, ואנחנו משלמים אותו — החלק הזה לעולם לא נוגע ביתרה שלכם. משיכה לכתובת שלכם היא עניין אחר: היא נושאת עמלה משלה, שמנוכה מהסכום שביקשתם, והמספרים המדויקים מופיעים בטבלת העמלות.

התחלה מהירה#

חמש דקות עד החשבונית הראשונה שלכם.

חמישה שלבים. שניים מהם לחיצות באזור החשבון שלכם, אחד הוא בקשה אחת מהשרת שלכם, ושני האחרונים קורים מעצמם.

  1. 1

    צרו חנות

    באזור החשבון שלכם. היא מתחילה לקבל תשלומים מיד — בלי לחכות לבדיקה. האימות מתבצע בשקט ברקע ומגביל רק משיכות, לא תשלומים נכנסים.

  2. 2

    הנפיקו מפתח API

    החנות שלכם ← מפתחות API ← מפתח חדש. המפתח וסוד הוובהוק מוצגים פעם אחת ולעולם לא שוב. שמרו עליהם כמו שהייתם שומרים סיסמת מסד נתונים, ולעולם אל תשלחו אותם לדפדפן.

  3. 3

    צרו חשבונית

    בקשה אחת מהשרת שלכם, קישור אחד בחזרה. ארבעת קטעי הקוד שלמטה שולחים בדיוק את אותו הדבר.

  4. 4

    הפנו את הקונה ל-payment_url

    זו כל הקופה — סכום, כתובת, קוד QR, ספירה לאחור, סטטוס בזמן אמת — ואין מה לבנות. ראו קופה כדי לראות מה הקונה באמת רואה.

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

אימות#

מפתח ה-API שלכם, ואיך הוא משמש.

כל בקשה נושאת את המפתח שלכם בכותרת Authorization:

http
Authorization: Bearer sk_live_IH4SNdnYsv-yabTuPCVln4vOvvGoEyxA

כל מפתח שמונפק כאן מתחיל ב-sk_live_. הקידומת sk_test_ קיימת רק בפריסה מול רשת בדיקה, ופריסה כזו אינה מוצעת — ראו בדיקות. אנחנו שומרים hash חד-כיווני, לא את המפתח עצמו, כך שאף אחד, כולל אנחנו, לא יכול להציג אותו לכם שוב. איבדתם אותו? הנפיקו חדש ובטלו את הישן.

החנות נגזרת מהמפתח, ולכן אף בקשה לעולם לא נושאת מזהה חנות. מפתח יכול לפעול רק על החנות שלו.

בכתובת יש גרסה: /api/merchant/v1/…. בתוך גרסה אנחנו רק מוסיפים שדות — שום דבר לא משנה שם ולא משנה משמעות בשקט. שינוי שישבור לכם את הקוד מקבל תחילית חדשה, /v2, ו-/v1 ממשיך לעבוד לתקופה שהוכרזה.

המפתח הזה יוצר חשבוניות בשמכם. שמרו אותו בצד השרת. כל דבר בקוד JavaScript של דפדפן הוא ציבורי, לא משנה כמה טוב הוא נראה מוסתר.

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.

יצירת חשבונית#

POST /api/merchant/v1/invoices

POST/api/merchant/v1/invoices

גוף הבקשה

שדהסוגחובהתיאור
assetstringכןTON או USDT_TON.
amountstringכןיחידות רגילות של המטבע, כמחרוזת: "5" הוא 5 USDT. לא יותר ספרות אחרי הנקודה ממה שיש למטבע. ראו סכומים.
order_idstringלאההפניה שלכם, עד 200 תווים. חוזרת בכל וובהוק — כך אתם מתאימים תשלום להזמנה.
descriptionstringלאעד 1000 תווים. מוצג לקונה בעמוד התשלום.
ttl_minutesnumberלאכמה זמן החשבונית נשארת ניתנת לתשלום, בדקות. 1–1440; אם תשמיטו אותו, חלה ברירת המחדל — שעתיים כיום.
idempotency_keystringלאעד 200 תווים. שלחו את אותו ערך בניסיון חוזר ותקבלו את אותה חשבונית במקום שנייה. זהו שדה בגוף הבקשה, ולא הכותרת Idempotency-Key — הכותרת הזאת אינה נקראת כאן.

תשובה · 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"
}

איך להשתמש בזה בהזמנה שלכם

שדהמה לעשות איתו
invoice_idשמרו אותו יחד עם ההזמנה שלכם. זה מה שמזהה את התשלום בכל מקום אחר.
payment_urlהפנו את הקונה לכאן. אין צורך לבנות שום דבר נוסף.
addressרק אם אתם בונים קופה משלכם. הציגו אותה בדיוק כפי שהתקבלה — ראו האזהרה למטה.
amountהסכום ביחידות רגילות, בדיוק כפי ששלחתם. את זה מציגים.
amount_minorאותו סכום כמספר שלם ביחידה הקטנה ביותר. איתו מחשבים.
expires_atהציגו ספירה לאחור. אחרי שהיא חולפת, הכתובת מפסיקה להיות מפוקחת עבור החשבונית הזו.
statusכאן תמיד pending. שינויים אמיתיים מגיעים דרך webhook.
אם אתם בונים עמוד משלכם, הדפיסו את הכתובת בדיוק כפי שהוחזרה. היא בצורה שאינה ניתנת להחזרה (UQ… ברשת הראשית, 0Q… ברשת הבדיקה). המרה, ייפוי, או החלפה בקידוד אחר של אותה כתובת יגרמו למטבעות שנשלחו לארנק שטרם נפרס לחזור לשולח.

קריאת חשבונית#

GET /api/merchant/v1/invoices/{invoice_id}

GET/api/merchant/v1/invoices/{invoice_id}

אותה צורה כמו למעלה, כאשר status, paid ו-paid_minor משקפים את ההווה: paid הוא כמה הגיע ביחידות רגילות, ו-paid_minor אותו סכום כמספר שלם ביחידה הקטנה ביותר. שימושי כגיבוי כאשר וובהוק הוחמץ, או בעמוד תודה.

בקשו אותה לכל היותר כל כמה שניות, והתייחסו ל-webhooks כערוץ העיקרי. חשבוניות ששייכות לחנות אחרת עונות 404 — לא 403, כך שלא ניתן לבדוק קיום של מזהה.

ביטול חשבונית#

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

POST/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 מהעברה מוקדמת יותר, האירוע נוגע להפקדה העודפת ולא לאותו תשלום.

מה מגיע

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"
  }
}

מיפוי שדות

שדהמשמעות
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העסקה בבלוקצ'יין, לרישומים שלכם ולתמיכה.

כותרות בכל משלוח

http
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-Signaturesha256= ואחריו ה-HMAC בהקסדצימלי. ראו למטה.

אימות החתימה

כל בקשה חתומה בסוד הוובהוק שמוצג פעם אחת בלבד, כשיצרתם את המפתח. החתימה היא HMAC-SHA256(secret, "{timestamp}.{raw_body}") — חותמת הזמן מ-X-Paysell-Timestamp, נקודה ממשית, ואז בייטים של הגוף. בדקו אותה לפני שאתם פועלים: בלי זה, כל מי שילמד את הכתובת שלכם יוכל להעביר לכם הזמנה משולמת.

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)
}

חתמו על בייטים גולמיים של הגוף, בדיוק כפי שהתקבלו. אם תפרסרו את ה-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.

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.

מדריך סטטוסים#

כל סטטוסי החשבוניות והתשלומים, מוסברים.

חשבונית

סטטוסמשמעותמה לעשות
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 בתשובה
TON9"1.5""1500000000"
USDT_TON6"1.5""1500000"

מחרוזת ולא מספר, כי מספרי JSON הם double לפי IEEE-754, וסכום גדול בננוטון מפסיק להיות מיוצג בהם במדויק. יותר ספרות אחרי הנקודה ממה שיש למטבע הוא 422, ולעולם לא עיגול שקט של הכסף שלכם. בוובהוקים זה הפוך: שם amount, fee ו-credited הם מספרים שלמים ביחידה הקטנה ביותר, כי את הצד ההוא קורא קוד ולא אדם.

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

מגבלות#

מינימום, מקסימום, ומגבלות קצב.

מגבלהערךבהפרה
חשבונית מינימלית0.1 TON · 3 USDT422
חשבונית מקסימלית7000 TON · 10000 USDT422
חשבוניות לשעה, לחנות60429
חשבוניות פתוחות בו-זמנית20, וגדל עם כל חשבונית משולמת, עד 200429
אורך חיי חשבוניתדקה אחת עד 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`: הניסוח עשוי להשתנות בכל רגע, הקוד לא.

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
    }
  ]
}
סטטוסמתימה לעשות
401המפתח חסר, שגוי, או בוטל.בדקו את הכותרת. הנפיקו מפתח חדש אם בוטל.
404אין חשבונית כזו, או שהיא שייכת לחנות אחרת.בדקו את המזהה. שני המקרים נענים באותו אופן במכוון, כדי שלא ניתן יהיה לגשש אחר מזהה.
409החשבונית במצב שאוסר את זה.קראו קודם את הסטטוס הנוכחי שלה.
422הבקשה בנויה לא נכון, או שהסכום מחוץ לגבולות החשבונית.ההודעה מציינת גם את הערך שנשלח וגם את המגבלה.
429יותר מדי חשבוניות בשעה הזו, יותר מדי פתוחות בו-זמנית, או יותר מדי בקשות.המתינו את Retry-After, ואז נסו שוב.
502לא הצלחנו להגיע לליבת העיבוד.נסו שוב עם אותו מפתח אידמפוטנטיות.

קודים

הצורה שאנחנו מכריעים בה היא {"detail": {"code": …, "message": …}}. אלה הקודים שממשק ה-API לסוחרים מחזיר.

קודסטטוסמשמעות
invalid_api_key401המפתח חסר, פגום, לא מוכר או בוטל. כל ארבעת המקרים נענים באותו אופן, כך שלא ניתן לגשש אחר מפתח.
not_found404אין אובייקט כזה, או שהוא שייך לחנות אחרת.
invalid_input422הבקשה לא עברה ולידציה בליבה — סכום שגוי, יותר מדי ספרות אחרי הנקודה, סכום מחוץ לגבולות החשבונית.
conflict409הפעולה סותרת את המצב הנוכחי, למשל ביטול חשבונית שאינה פתוחה עוד.
too_many_requests429מגבלת קצב: חשבוניות לשעה, חשבוניות פתוחות, או בקשות לדקה. Retry-After אומר כמה זמן להמתין.
cbc_unreachable502לא הצלחנו להגיע לליבת העיבוד. נסו שוב עם אותו idempotency_key.
webhook_url_rejected422רק בשמירת מפתח: כתובת הוובהוק נכשלה בבדיקות שלמעלה. 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 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.

החזרים#

איך להחזיר כסף ללקוח.

החזרים מתבצעים דרך התמיכה, לא בקריאת 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

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.

רשימת בדיקה לפני השקה#

עשרה דברים לבדוק לפני ההשקה.

  • המפתח נמצא רק בצד השרת, לעולם לא בקוד 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, ולא בניסיון חוזר מיידי.
  • יתרות נקראות מאיתנו, לא נעקבות בנפרד כאמת.

משהו לא ברור?

אם העמוד הזה לא ענה על השאלה שלכם, זהו פער בתיעוד שכדאי לספר לנו עליו. כתבו לנו מאזור החשבון שלכם ואנחנו נתקן את העמוד, לא רק את התשובה.