رفتن به محتوا

دریافت Webhook

هر اتفاقی بیفتد، رومو خودش به سرور شما خبر می‌دهد. پس لازم نیست مدام بپرسید. فقط پیش از استفاده بسنجید که خبر واقعاً از رومو آمده است.

در کنسول به «کلیدهای API و Webhook» و زبانه‌ی «Webhook» بروید. این صفحه «نشانی‌های دریافت» است. تا 10 نشانی می‌توانید داشته باشید و هر نشانی رویدادهای خودش را می‌گیرد. هر رویداد به همه‌ی نشانی‌هایی می‌رود که آن را خواسته‌اند. افزودن و تغییر فقط با مدیر سازمان و ورود تازه است.

  1. «افزودن نشانی» را بزنید.
  2. «نام» را بنویسید: نامی که خودتان بشناسید، تا 40 نویسه.
  3. «نشانی (https)» را بدهید: فقط https⁠، با نام میزبان به حروف کوچک (نه IP)، بی شماره‌ی درگاه (ارسال به درگاه 443 می‌رود)، بی نام کاربری و بی #⁠، حداکثر 2048 بایت.
  4. «رویدادها» را انتخاب کنید و «پیشنهاد افزودن» را بزنید.
  5. در اپ رومو تأیید کنید. تا تأیید نشود نشانی کار نمی‌کند.

تغییر، ادامه‌ی ارسال و حذف هم همین‌طور با تأیید در اپ انجام می‌شوند. «توقف ارسال» تأیید نمی‌خواهد و همان لحظه اثر می‌کند. تا 30 ثانیه پس از اعمال یک تغییر ممکن است چند ارسال هنوز با تنظیم قبلی برود.

نشانی‌های داخلی، loopback و مانند آن‌ها هنگام هر ارسال رد می‌شوند و رومو redirect را دنبال نمی‌کند.

درخواستی که می‌رسد

بخش «درخواستی که می‌رسد»
POST /your/path
Content-Type: application/json
X-Custody-Event-Id: 0192f1a4-2b6d-7e01-8f3a-9c4d5e6f7a81
X-Custody-Signature: +7gHePoedVfn6gaJztgzgXJdp9/09CG6EMntJGbNnh1LoGIXbniSjcS+RN9gf8Hufmy8VL39eHixxUa03KoVAQ==
X-Custody-Key-Version: 1
X-Custody-Delivery-Attempt: 1

X-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 بخوانید.

کد آماده، آزموده با نمونه‌های خود رومو:

verify-webhook.ts
// 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;
}

اگر سنجش خطا داد، 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 1
org_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 روز گذشته را دوباره می‌فرستد.