وبهوکهای API نماینده
رویدادهای وبهوک، ساختار payloadها، تأیید امضای HMAC در Python و Node، تلاش مجدد با backoff، قواعد ایمنی URL و حذف تکرار بر پایهٔ شناسهٔ رویداد.
رویدادها و محتوای وبهوک، بررسی امضای HMAC در Python و Node، تلاش دوباره با فاصلهٔ فزاینده، قواعد امنیت URL و حذف تکرارها با شناسهٔ رویداد.
همهٔ مدلها، قابلیتها و قیمتها فهرست مستندات برای عاملهای هوش مصنوعی:https://oneprovider.dev/docs/llms.txt ساخت وبهوک
POST /webhooks (دسترسی webhooks): یک URL فقط https و فهرست غیرخالی از رویدادها. حداکثر 5 وبهوک. Idempotency-Key اختیاری است: تکرار همان وبهوک را بدون راز برمیگرداند. last_delivered_at زمان آخرین پاسخ 2xx سرور شماست (تا وقتی پاسخی نبوده null است).
curl -s -X POST https://app.oneprovider.dev/partner/v1/webhooks \
-H "Authorization: Bearer $OP_PARTNER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"url":"https://shop.example.com/op-webhook","events":["order.completed","balance.low"]}' json {
"id": "<webhook id>",
"url": "https://shop.example.com/op-webhook",
"events": ["order.completed", "balance.low"],
"active": true,
"secret_last4": "a1b2",
"created_at": "2026-10-08T12:00:00Z",
"last_delivered_at": null,
"secret": "whsec_..."
} راز امضا فقط یک بار نمایش داده میشود فیلد secret فقط در پاسخ ساخت میآید. آن را روی سرور خودتان نگه دارید؛ رازِ گمشده بازیابی نمیشود و باید وبهوک را دوباره بسازید. رویدادها
| رویداد | چه زمانی | data |
|---|---|---|
key.created | کلیدی صادر شد (از API، حساب کاربری، یک سفارش یا یک کد). | key_id, name, limit_usd, customer_id, source (api, cabinet, order, code) |
key.paused | کلیدی متوقف شد (دستی یا هنگام مسدود شدن مشتری آن). | key_id، reason (فقط برای مسدودسازی: customer_blocked) |
key.resumed | کلیدی از سر گرفته شد (دستی یا هنگام رفع مسدودیت مشتری آن). | key_id، reason (فقط برای رفع مسدودیت: customer_unblocked) |
code.redeemed | کدی فعال شد. | batch_id, code_tail, key_id, amount_usd |
order.completed | سفارشی کامل شد. | order_id, external_order_id, kind, key_id, amount_usd |
balance.topped_up | موجودی نماینده فروش شارژ شد. | amount_usd, order_id |
balance.low | موجودی از آستانه پایینتر رفت (آستانه را OneProvider تعیین میکند، پیشفرض $10 و برای همه یکسان). یک بار میآید و دوباره فقط پس از آنکه موجودی بالای آستانه رفته باشد. | balance_usd, threshold_usd |
key.exhausted | سقف یک کلید تمام شد. برای هر کلید و هر سقف فروختهشده یک بار، ظرف چند دقیقه؛ با شارژ کلید، این رویداد دوباره فعال میشود. | key_id, limit_usd |
دادهٔ رویداد هرگز راز ندارد: نه کلید، نه توکن و نه کد. آزمایش (POST /webhooks/{id}/test) رویداد سرویسی webhook.test را با دادهٔ {"message": "test"} میفرستد؛ نمیتوان مشترک آن شد.
درخواست و امضا
ما یک POST با بدنهٔ JSON میفرستیم:
json{
"id": "evt_...",
"type": "order.completed",
"created_at": "2026-10-08T12:00:00Z",
"data": {
"order_id": 42,
"external_order_id": "shop-1042",
"kind": "new_key",
"key_id": "<key id>",
"amount_usd": "10.00"
}
} بدنه بهصورت JSON فشرده و با فیلدهای مرتبشده بر اساس حروف الفبا ارسال میشود؛ امضا را روی همان بایتهایی که رسیدهاند بررسی کنید، نه روی JSON بازسازیشده.
| هدر | مقدار |
|---|---|
X-OP-Signature | t=<unix>,v1=<hex> |
X-OP-Event | نوع رویداد. |
X-OP-Delivery | برابر شناسهٔ رویداد. |
User-Agent | OneProvider-Webhooks/1 |
v1 مقدار هگزادسیمال HMAC-SHA256 است که کلید آن راز وبهوک (whsec_...) و پیامش رشتهٔ "<t>" + "." + raw_body است.
بررسی امضا
- بدنهٔ خام درخواست را بخوانید: امضا همان بایتهای دقیق را پوشش میدهد و JSON دوباره سریالایزشده مطابقت نخواهد داشت.
- HMAC را دوباره حساب کنید و با v1 در زمان ثابت مقایسه کنید (hmac.compare_digest، timingSafeEqual).
- اگر |now - t| بیشتر از 5 دقیقه بود درخواست را رد کنید تا نتوان امضای قدیمی را دوباره به کار برد (replay).
- سریع با 2xx پاسخ دهید (مهلت 10 ثانیه است) و کارهای سنگین را ناهمگام انجام دهید.
Python
pythonimport hashlib
import hmac
import time
TOLERANCE = 300 # seconds
def verify(secret: str, header: str, raw_body: bytes) -> bool:
"""header is X-OP-Signature: t=<unix>,v1=<hex>; raw_body is the unparsed request body."""
parts = dict(p.strip().partition("=")[::2] for p in header.split(","))
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not t.isdigit() or abs(time.time() - int(t)) > TOLERANCE:
return False
signed = t.encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1) python import json
import os
from fastapi import FastAPI, HTTPException, Request
app = FastAPI()
SECRET = os.environ["OP_WEBHOOK_SECRET"] # whsec_...
seen: set[str] = set() # use a database table with a unique index in production
@app.post("/op-webhook")
async def op_webhook(request: Request):
raw = await request.body() # the raw bytes, before any JSON parsing
if not verify(SECRET, request.headers.get("X-OP-Signature", ""), raw):
raise HTTPException(status_code=400, detail="bad signature")
event = json.loads(raw)
if event["id"] in seen: # retries resend the same event id
return {"ok": True}
seen.add(event["id"])
if event["type"] == "order.completed":
... # your logic
return {"ok": True} # any 2xx marks the event as delivered Node.js
javascriptimport crypto from "node:crypto"
import express from "express"
const SECRET = process.env.OP_WEBHOOK_SECRET // whsec_...
const TOLERANCE = 300 // seconds
const seen = new Set() // use a database table with a unique index in production
function verify(secret, header, raw) {
const parts = Object.fromEntries(
String(header).split(",").map((p) => p.trim().split("=")),
)
const t = Number(parts.t)
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > TOLERANCE) return false
const expected = crypto
.createHmac("sha256", secret)
.update(parts.t + ".")
.update(raw)
.digest("hex")
const a = Buffer.from(expected)
const b = Buffer.from(parts.v1 ?? "")
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
const app = express()
// express.raw keeps the body as a Buffer: do not use express.json() on this route
app.post("/op-webhook", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(SECRET, req.get("X-OP-Signature"), req.body)) return res.sendStatus(400)
const event = JSON.parse(req.body.toString("utf8"))
if (!seen.has(event.id)) {
seen.add(event.id) // retries resend the same event id
if (event.type === "order.completed") {
// your logic
}
}
res.sendStatus(200) // any 2xx marks the event as delivered
})
app.listen(3000) تلاش دوباره
فقط پاسخ 2xx تحویلشده حساب میشود. هر چیز دیگر (از جمله 3xx، چون ریدایرکت دنبال نمیشود، و نیز تایماوت و خطای اتصال) با فاصلهٔ نمایی دوباره تلاش میشود: 1 دقیقه، 2، 4 و همینطور ادامه تا سقف 4 ساعت بین دو تلاش، تا 24 ساعت پس از رویداد؛ پس از آن تحویل failed میشود. هر تلاش همان شناسهٔ رویداد را دارد، پس بر اساس آن تکرارها را حذف کنید: شناسههای پردازششده را در جدولی با شاخص یکتا نگه دارید.
قواعد URL
- فقط https، پورت 443 (هر پورت دیگری 400 invalid_url است) و یک نام میزبان؛ آدرس IP مستقیم رد میشود. URL حداکثر 500 نویسهٔ ASCII است، بدون نام کاربری، گذرواژه یا فرگمنت.
- هر نشانیای که نام به آن resolve میشود نباید خصوصی، loopback، link-local یا رزروشده باشد.
- بررسی هنگام ساخت و در هر ارسال انجام میشود. تخلف هنگام ساخت 400
invalid_urlمیدهد.
آزمایش و گزارش
bashcurl -s -X POST https://app.oneprovider.dev/partner/v1/webhooks/<webhook id>/test -H "Authorization: Bearer $OP_PARTNER_TOKEN"
curl -s "https://app.oneprovider.dev/partner/v1/webhooks/<webhook id>/deliveries?limit=20" -H "Authorization: Bearer $OP_PARTNER_TOKEN" شیء ارسال: id، event_id، event، status (pending، delivered، failed)، attempts، next_attempt_at، last_error (http_<status>، timeout، blocked_address یا نام خطای اتصال)، last_status (وضعیت HTTP که سرور شما پاسخ داد؛ اگر پاسخ نداد null)، last_body (ابتدای پاسخ، تا 300 نویسه، در یک خط)، created_at. حذف وبهوک: DELETE /webhooks/{id}. آزمایشها و تلاشهای دستی (در حساب کاربری) روی هم در هر دقیقه حداکثر 6 بار مجازند؛ فقط رویداد تحویلنشده را میتوان دوباره فرستاد و برای تحویلشده 409 already_delivered برمیگردد. تلاش دستی فقط در حساب کاربری ممکن است، نه با توکن.