دریافت Webhook
هر اتفاقی بیفتد، رومو خودش به سرور شما خبر میدهد. پس لازم نیست مدام بپرسید. فقط پیش از استفاده بسنجید که خبر واقعاً از رومو آمده است.
تنظیم
بخش «تنظیم»در کنسول به «کلیدهای API و Webhook» و زبانهی «Webhook» بروید. این صفحه «نشانیهای دریافت» است. تا 10 نشانی میتوانید داشته باشید و هر نشانی رویدادهای خودش را میگیرد. هر رویداد به همهی نشانیهایی میرود که آن را خواستهاند. افزودن و تغییر فقط با مدیر سازمان و ورود تازه است.
- «افزودن نشانی» را بزنید.
- «نام» را بنویسید: نامی که خودتان بشناسید، تا 40 نویسه.
- «نشانی (https)» را بدهید: فقط
https، با نام میزبان به حروف کوچک (نه IP)، بی شمارهی درگاه (ارسال به درگاه 443 میرود)، بی نام کاربری و بی#، حداکثر 2048 بایت. - «رویدادها» را انتخاب کنید و «پیشنهاد افزودن» را بزنید.
- در اپ رومو تأیید کنید. تا تأیید نشود نشانی کار نمیکند.
تغییر، ادامهی ارسال و حذف هم همینطور با تأیید در اپ انجام میشوند. «توقف ارسال» تأیید نمیخواهد و همان لحظه اثر میکند. تا 30 ثانیه پس از اعمال یک تغییر ممکن است چند ارسال هنوز با تنظیم قبلی برود.
نشانیهای داخلی، loopback و مانند آنها هنگام هر ارسال رد میشوند و رومو redirect را دنبال نمیکند.
درخواستی که میرسد
بخش «درخواستی که میرسد»POST /your/pathContent-Type: application/jsonX-Custody-Event-Id: 0192f1a4-2b6d-7e01-8f3a-9c4d5e6f7a81X-Custody-Signature: +7gHePoedVfn6gaJztgzgXJdp9/09CG6EMntJGbNnh1LoGIXbniSjcS+RN9gf8Hufmy8VL39eHixxUa03KoVAQ==X-Custody-Key-Version: 1X-Custody-Delivery-Attempt: 1X-Custody-Delivery-Attempt شمارهی تلاش است و بیرون از امضاست. وقتی رویداد به یکی از نشانیهای شما میرود، X-Custody-Endpoint-Id هم میآید: شناسهی همان نشانی. این هم بیرون از امضاست.
بدنهی یک رویداد تراکنش:
{ "version": 2, "event_id": "0192f1a4-2b6d-7e01-8f3a-9c4d5e6f7a81", "org_id": "0192f1a0-7c3e-7b42-9d1e-5a8b3c6d2e10", "kind": "transaction.updated", "timestamp": "2026-10-01T12:34:56.123456Z", "data": { "tx_id": "0192f1a2-5e4f-7a10-b2c3-d4e5f6a7b8c9", "type": "withdrawal", "origin": "api", "idempotency_key": "withdraw-8841", "external_ref": "user-1207", "wallet_id": "0192f19f-1d2c-7f3b-8a4e-6b7c8d9e0f12", "network": "TRON", "asset": "TRON_USDT", "to_address": "TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE", "amount": "1500", "amount_raw": "1500000000", "status": "confirmed", "tx_hash": "a3f1c2d4e5b6978877665544332211ffeeddccbbaa99887766554433221100aa", "fee_actual": "0.345" }}بدنهی واقعی یک خط و بی فاصله است. بالا برای خواندن مرتب شده است.
- با
data.idempotency_keyوdata.external_refرویداد را به درخواست خودتان وصل کنید. tx_hash،fee_actualوreasonممکن است نیایند. کد شما باید نبودنشان را بپذیرد.- رأی قانون در بدنه نیست. برای جزئیات
GET /v1/transactions?id=را بخوانید.
رویدادها
فهرست بسته است و 6 رویداد دارد. فقط رویدادهایی که در تنظیم انتخاب کردهاید فرستاده میشوند. «همه» یعنی همین فهرست صریح، پس رویداد تازهی آینده بیخبر فرستاده نمیشود.
| گروه | kind |
|---|---|
| تراکنش | transaction.updated |
| هشدار | transaction.double_spend_risk |
| واریز | deposit.detected، deposit.confirmed |
| sweep | sweep.completed |
| ایستگاه کارمزد | fee_station.low |
transaction.updated با هر تغییر برداشت میآید و status را دارد. stage فقط در pending و checking میآید و reason فقط در failed. گاهی reason_detail هم میآید: توضیحی فارسی برای نمایش. کد خودتان را روی آن ننویسید، روی reason بنویسید.
تراکنش خود sweep رویداد transaction.* ندارد و پایانش فقط sweep.completed است.
transaction.double_spend_risk یک هشدار است، نه تغییر وضعیت: تراکنشی پس از فرستادن جانشینش روی شبکه نشسته و ممکن است پول دو بار رفته باشد و کیف پول متوقف شده است. فوری با رومو در میان بگذارید.
شکل بدنهی هر گروه در فایل OpenAPI زیر بخش webhooks است.
رویداد و تأیید فرق دارند
بخش «رویداد و تأیید فرق دارند»Webhook فقط خبر است. سیستم شما با پاسخ دادن به آن چیزی را تأیید یا رد نمیکند. تأیید فقط در اپ رومو انجام میشود. به قانونها و تأییدها نگاه کنید.
سنجش امضا
بخش «سنجش امضا»پیام امضاشده دقیقاً 150 بایت است، بی جداکننده:
| بخش | بایت |
|---|---|
custody.webhook.v1 |
18 |
0x00 |
1 |
org_id به صورت متن |
36 |
event_id به صورت متن |
36 |
timestamp، همان رشتهی داخل بدنه |
27 |
| sha256 خام بایتهای بدنه | 32 |
امضا در X-Custody-Signature با base64 استاندارد است (با =). امضای درخواستهای شما base64url است؛ این دو را با هم اشتباه نگیرید.
کلید عمومی را از GET /v1/webhooks/key بگیرید و با نسخهاش نگه دارید. اگر X-Custody-Key-Version ناشناختهای رسید، فهرست را دوباره بخوانید. org_id سازمانتان را از GET /v1/whoami بخوانید.
کد آماده، آزموده با نمونههای خود رومو:
// Check one webhook delivery (Node 24 or later, node:crypto only).import { createHash, createPublicKey, verify, type KeyObject } from "node:crypto";
// publicKey turns one public_key of GET /v1/webhooks/key (base64 of 32 bytes) into a key.export function publicKey(b64: string): KeyObject { const der = Buffer.concat([Buffer.from("302a300506032b6570032100", "hex"), Buffer.from(b64, "base64")]); return createPublicKey({ key: der, format: "der", type: "spki" });}
type Headers = Record<string, string | undefined>;
// In Express, read the body as received: app.post(path, express.raw({ type: "application/json" }), handler).// verifyWebhook takes the exact bytes received and lower-case header names, and throws on any failure.export function verifyWebhook(rawBody: Buffer, headers: Headers, keys: Map<number, KeyObject>, myOrgId: string) { const ev = JSON.parse(rawBody.toString("utf8")); const key = keys.get(Number(headers["x-custody-key-version"])); if (!key) throw new Error("unknown key version: read GET /v1/webhooks/key again"); if (ev.org_id !== myOrgId) throw new Error("another organisation"); if (ev.event_id !== headers["x-custody-event-id"]) throw new Error("event id differs"); const message = Buffer.concat([ Buffer.from("custody.webhook.v1"), Buffer.from([0]), Buffer.from(ev.org_id), Buffer.from(ev.event_id), Buffer.from(ev.timestamp), createHash("sha256").update(rawBody).digest(), ]); const signature = Buffer.from(headers["x-custody-signature"] ?? "", "base64"); if (message.length !== 150 || !verify(null, message, key, signature)) throw new Error("bad signature"); return ev;}package romu
import ( "crypto/ed25519" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "net/http" "strconv")
// VerifyWebhook checks one delivery; body is the exact bytes received, keys maps key_version to the// 32-byte public key.func VerifyWebhook(body []byte, h http.Header, orgID string, keys map[int][]byte) (map[string]any, error) { var ev struct { OrgID string `json:"org_id"` EventID string `json:"event_id"` Timestamp string `json:"timestamp"` } if err := json.Unmarshal(body, &ev); err != nil { return nil, err } if ev.EventID != h.Get("X-Custody-Event-Id") || ev.OrgID != orgID { return nil, errors.New("event or organisation does not match") } version, err := strconv.Atoi(h.Get("X-Custody-Key-Version")) if err != nil { return nil, err } key, ok := keys[version] if !ok { return nil, errors.New("unknown key version: read GET /v1/webhooks/key again") } signature, err := base64.StdEncoding.DecodeString(h.Get("X-Custody-Signature")) if err != nil { return nil, err } sum := sha256.Sum256(body) message := append([]byte("custody.webhook.v1\x00"), ev.OrgID+ev.EventID+ev.Timestamp...) message = append(message, sum[:]...) if len(message) != 150 || !ed25519.Verify(key, message, signature) { return nil, errors.New("bad signature") } var out map[string]any return out, json.Unmarshal(body, &out)}"""Check one webhook delivery (pip install cryptography)."""import base64import hashlibimport json
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
def verify_webhook(body, headers, org_id, keys): """body is the exact bytes received; keys maps key_version to the 32-byte public key. Raises on any failure.""" ev = json.loads(body) if ev["event_id"] != headers["X-Custody-Event-Id"] or ev["org_id"] != org_id: raise ValueError("event or organisation does not match") key = keys[int(headers["X-Custody-Key-Version"])] # KeyError: read GET /v1/webhooks/key again signature = base64.b64decode(headers["X-Custody-Signature"], validate=True) message = (b"custody.webhook.v1\x00" + ev["org_id"].encode() + ev["event_id"].encode() + ev["timestamp"].encode() + hashlib.sha256(body).digest()) if len(message) != 150: raise ValueError("bad message") Ed25519PublicKey.from_public_bytes(key).verify(signature, message) # InvalidSignature return evاگر سنجش خطا داد، 400 بدهید و رویداد را به کار نبرید.
پاسخ شما
بخش «پاسخ شما»هر پاسخ 2xx در 10 ثانیه یعنی «رسید». اول امضا را بسنجید و رویداد را ذخیره کنید، بعد سریع 200 بدهید و کار سنگین را بعد انجام دهید.
تلاش دوباره
بخش «تلاش دوباره»اگر پاسخ 2xx نرسد، رومو تا 8 بار میفرستد. زمانها از لحظهی ساخت رویداد است و هر کدام ±10٪ جابهجا میشود.
| تلاش | زمان پس از ساخت رویداد |
|---|---|
| 1 | فوری |
| 2 | 1 دقیقه |
| 3 | 5 دقیقه |
| 4 | 30 دقیقه |
| 5 | 2 ساعت |
| 6 | 6 ساعت |
| 7 | 12 ساعت |
| 8 | 24 ساعت |
پس از هشتمین شکست، رویداد «ناموفق» میشود و دیگر خودکار فرستاده نمیشود.
تکرار و ترتیب
بخش «تکرار و ترتیب»- هر رویداد حداقل یک بار میرسد، نه دقیقاً یک بار. تکراری را با
event_idبشناسید و نادیده بگیرید. - ترتیب رسیدن قطعی نیست. ترتیب را با
timestampوstatusبسازید. رویداد قدیمیتر نباید وضعیت تازهتر را عقب ببرد.
ارسال دوباره
بخش «ارسال دوباره»| مسیر | بدنه | سقف در دقیقه |
|---|---|---|
POST /v1/webhooks/replay |
{"event_id":"..."} |
60 برای هر سازمان |
POST /v1/webhooks/replay-failed |
{"from":"...","to":"..."} |
5 برای هر سازمان |
همان بدنه و همان امضا دوباره فرستاده میشود و یک دور تازهی 8 تلاشی شروع میشود. رویداد به همهی نشانیهایی میرود که آن را خواستهاند. فقط X-Custody-Delivery-Attempt فرق دارد. در replay-failed بازه حداکثر 7 روز و 500 رویداد است.
پس از قطعی سرور شما: وقتی سرور بالا آمد، replay-failed را برای بازهی قطعی بزنید و برای اطمینان، وضعیت تراکنشهای باز را با GET /v1/transactions هم بخوانید.
آزمون
بخش «آزمون»آزمون سرور شما با نمونهی امضاشده
بخش «آزمون سرور شما با نمونهی امضاشده»یک رویداد واقعینما با امضای درست اینجاست. آن را به سرور خودتان بفرستید و ببینید سنجش شما میپذیرد. برای این آزمون، کلید عمومی و سازمان نمونه را در سرور آزمون بگذارید:
public_key VSX1y38XE3pPle9xXdo8QY98yVtH+PNMoZFPlX5Jt5o=key_version 1org_id 0192f1a0-7c3e-7b42-9d1e-5a8b3c6d2e10این کلید فقط برای آزمون است. در تولید فقط کلید GET /v1/webhooks/key را قبول کنید.
curl -sO https://docs.romu.io/samples/webhook-event.json
curl -i -X POST http://localhost:3000/webhooks/romu \ -H 'Content-Type: application/json' \ -H 'X-Custody-Event-Id: 0192f1a4-2b6d-7e01-8f3a-9c4d5e6f7a81' \ -H 'X-Custody-Key-Version: 1' \ -H 'X-Custody-Delivery-Attempt: 1' \ -H 'X-Custody-Signature: +7gHePoedVfn6gaJztgzgXJdp9/09CG6EMntJGbNnh1LoGIXbniSjcS+RN9gf8Hufmy8VL39eHixxUa03KoVAQ==' \ --data-binary @webhook-event.jsonحالا یک حرف فایل را عوض کنید و دوباره بفرستید. سرور شما باید 400 بدهد.
آزمون با پیام آزمایشی رومو
بخش «آزمون با پیام آزمایشی رومو»در صفحهی «نشانیهای دریافت»، کنار هر نشانی دکمهی «ارسال پیام آزمایشی» هست. رومو یک پیام با kind برابر webhook.test و data خالی میفرستد، با همان امضا و همان سرفصلها. این پیام جزو 6 رویداد بالا نیست و انتخابی هم نیست: فقط همین دکمه آن را میفرستد. اگر کدتان kind ناشناخته را رد میکند، این یکی را بپذیرید. برای هر سازمان دقیقهای یک بار میشود و فقط مدیر، با ورود تازه.
آزمون با رویداد واقعی
بخش «آزمون با رویداد واقعی»یک برداشت کوچک بسازید (در sandbox پول آزمایشی است)، یا یک رویداد قبلی را با replay دوباره بفرستید. در صفحهی «نشانیهای دریافت»، «ارسال دوبارهی ناموفقها» رویدادهای ناموفق 7 روز گذشته را دوباره میفرستد.