Webhooks de l’API revendeur
Événements webhook, charges utiles, signature HMAC en Python et Node, retries avec backoff, sécurité des URL et déduplication par ID d’événement.
Événements webhook, charges utiles, vérification de la signature HMAC en Python et Node, nouvelles tentatives avec backoff, règles de sécurité des URL et déduplication par identifiant d’événement.
Tous les modèles, leurs capacités et leurs tarifs Index de la documentation pour les agents IA :https://oneprovider.dev/docs/llms.txt Créer un webhook
POST /webhooks (autorisation webhooks) : une URL https uniquement et une liste d’événements non vide. Au plus 5 webhooks. Une Idempotency-Key facultative : une répétition renvoie le même webhook, sans le secret. last_delivered_at est l’heure de la dernière réponse 2xx de votre serveur (null tant qu’il n’y en a pas eu).
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_..."
} Le secret de signature n’est affiché qu’une fois Le champ secret n’apparaît que dans la réponse de création. Conservez-le sur votre serveur ; un secret perdu ne peut pas être récupéré, recréez le webhook. Événements
| Événement | Quand | data |
|---|---|---|
key.created | Une clé a été émise (depuis l’API, l’espace client, une commande ou un code). | key_id, name, limit_usd, customer_id, source (api, cabinet, order, code) |
key.paused | Une clé a été mise en pause (à la main ou lors du blocage de son client). | key_id, reason (uniquement pour un blocage : customer_blocked) |
key.resumed | Une clé a été reprise (à la main ou lors du déblocage de son client). | key_id, reason (uniquement pour un déblocage : customer_unblocked) |
code.redeemed | Un code a été activé. | batch_id, code_tail, key_id, amount_usd |
order.completed | Une commande a été terminée. | order_id, external_order_id, kind, key_id, amount_usd |
balance.topped_up | Le solde revendeur a été rechargé. | amount_usd, order_id |
balance.low | Le solde est passé sous le seuil (fixé par OneProvider, 10 $ par défaut, identique pour tous). L’événement arrive une seule fois, et de nouveau seulement après que le solde est repassé au-dessus du seuil. | balance_usd, threshold_usd |
key.exhausted | La limite d’une clé est épuisée. Une fois par clé et par limite vendue, en quelques minutes ; une recharge réarme l’événement. | key_id, limit_usd |
Les données d’un événement ne contiennent jamais de secrets : ni clés, ni jetons, ni codes. Le test (POST /webhooks/{id}/test) envoie l’événement de service webhook.test avec data {"message": "test"} ; vous ne pouvez pas vous y abonner.
Requête et signature
Nous envoyons un POST avec un corps 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"
}
} Le corps est envoyé en JSON compact, avec les clés par ordre alphabétique ; vérifiez la signature sur les octets exactement tels qu’ils sont arrivés, pas sur un JSON reconstruit.
| En-tête | Valeur |
|---|---|
X-OP-Signature | t=<unix>,v1=<hex> |
X-OP-Event | Le type d’événement. |
X-OP-Delivery | Égal à l’identifiant de l’événement. |
User-Agent | OneProvider-Webhooks/1 |
v1 est l’hexadécimal d’un HMAC-SHA256 dont la clé est le secret du webhook (whsec_...) et dont le message est la chaîne "<t>" + "." + raw_body.
Vérifier la signature
- Lisez le corps brut de la requête : la signature porte sur les octets exacts, un JSON re-sérialisé ne correspondra pas.
- Recalculez le HMAC et comparez-le à v1 en temps constant (hmac.compare_digest, timingSafeEqual).
- Rejetez la requête si |now - t| dépasse 5 minutes, afin qu’une ancienne signature ne puisse pas être rejouée.
- Répondez 2xx rapidement (le délai d’attente est de 10 secondes) et faites le travail lourd de façon asynchrone.
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) Nouvelles tentatives
Seule une réponse 2xx compte comme livrée. Tout le reste (y compris 3xx, puisque les redirections ne sont pas suivies, ainsi que les délais dépassés et les erreurs de connexion) est réessayé avec un backoff exponentiel : 1 minute, 2, 4 et ainsi de suite, avec un intervalle entre deux essais plafonné à 4 heures, jusqu’à 24 heures après l’événement ; ensuite la livraison passe à failed. Chaque nouvel essai porte le même identifiant d’événement, dédupliquez donc dessus : conservez les identifiants traités dans une table avec un index unique.
Règles pour les URL
- https uniquement, port 443 (tout autre port donne 400 invalid_url) et un nom d’hôte ; les adresses IP brutes sont refusées. L’URL fait au plus 500 caractères ASCII, sans nom d’utilisateur, mot de passe ni fragment.
- Chaque adresse vers laquelle le nom se résout ne doit être ni privée, ni de bouclage, ni link-local, ni réservée.
- La vérification s’exécute à la création et à chaque livraison. Une violation à la création donne 400
invalid_url.
Test et journal
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" Objet livraison : id, event_id, event, status (pending, delivered, failed), attempts, next_attempt_at, last_error (http_<status>, timeout, blocked_address ou le nom de l’erreur de connexion), last_status (le statut HTTP de la réponse de votre serveur, null s’il n’a pas répondu), last_body (le début de la réponse, jusqu’à 300 caractères, sur une ligne), created_at. Supprimer un webhook : DELETE /webhooks/{id}. Les tests et les nouveaux essais manuels (dans l’espace client) sont limités ensemble à 6 par minute ; seul un événement non livré peut être réessayé, un événement livré donne 409 already_delivered. L’essai manuel n’est disponible que dans l’espace client, pas avec un jeton.