OneProvider

وب‌هوک‌های 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 است).

bash
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-Signaturet=<unix>,v1=<hex>
X-OP-Eventنوع رویداد.
X-OP-Deliveryبرابر شناسهٔ رویداد.
User-AgentOneProvider-Webhooks/1

v1 مقدار هگزادسیمال HMAC-SHA256 است که کلید آن راز وب‌هوک (whsec_...) و پیامش رشتهٔ "<t>" + "." + raw_body است.

بررسی امضا

  1. بدنهٔ خام درخواست را بخوانید: امضا همان بایت‌های دقیق را پوشش می‌دهد و JSON دوباره سریالایزشده مطابقت نخواهد داشت.
  2. HMAC را دوباره حساب کنید و با v1 در زمان ثابت مقایسه کنید (hmac.compare_digest، timingSafeEqual).
  3. اگر |now - t| بیشتر از 5 دقیقه بود درخواست را رد کنید تا نتوان امضای قدیمی را دوباره به کار برد (replay).
  4. سریع با 2xx پاسخ دهید (مهلت 10 ثانیه است) و کارهای سنگین را ناهمگام انجام دهید.

Python

python
import 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

javascript
import 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 می‌دهد.

آزمایش و گزارش

bash
curl -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 برمی‌گردد. تلاش دستی فقط در حساب کاربری ممکن است، نه با توکن.

آخرین به‌روزرسانی: 2026-11-08 باگی پیدا کردید؟ @Oneproviderhelpbot