Webhooks de paiement
Fonctionnalité en cours de déploiement. L'interface décrite ici est celle de la prochaine livraison : elle peut encore évoluer, et n'est pas forcément activée sur votre tenant.
À quoi ça sert
Un BillingWebhook est une adresse de votre application que la plateforme appelle à chaque
événement de paiement : paiement réussi, échéance impayée, facture émise, remboursement… C'est
à partir de ces événements que votre application met à jour ses commandes et écrit à vos
clients : la plateforme n'envoie aucun e-mail à vos clients.
Prérequis
- Un compte de paiement.
- Une adresse
https://publique dans votre application, qui acceptePOST.
1. Créer le secret de signature
Chaque appel est signé avec un secret que vous seul et la plateforme connaissez. Faites-le générer comme un secret ordinaire (voir Créer et lire un secret) :
sng secret create shop-billing-webhook --length 48 --contains letters,numbers
sng secret get shop-billing-webhook --key value # à donner à votre application
Passez-le à votre application comme vos autres secrets (voir Injecter un secret dans une App).
2. Déclarer le webhook
apiVersion: billing.stackngo.io/v1alpha1
kind: BillingWebhook
metadata:
name: shop
spec:
url: https://shop.acme.example/hooks/billing # https obligatoire
events:
- payment.succeeded
- payment.failed
- charge.action_required
- charge.failed
- subscription.past_due
- subscription.canceled
- invoice.finalized
- refund.succeeded
secretRef: { name: shop-billing-webhook, key: value } # facultatif
enabled: true
Sans secretRef, un secret de signature est généré pour vous dans le secret
<nom>-billing-webhook (clé secret). Si vous fournissez le vôtre, il doit compter au moins
16 caractères.
sng apply billing/webhook.yml
Vous pouvez déclarer plusieurs BillingWebhook (une adresse par application, par exemple).
Les événements
| Événement | Quand |
|---|---|
payment.succeeded | un panier a été payé |
payment.failed | un paiement sur la page de paiement a échoué |
charge.succeeded | un débit à l'usage a réussi |
charge.action_required | un débit demande une authentification forte de votre client (action_url) |
charge.failed | un débit a été refusé |
subscription.created | un abonnement a démarré (première échéance payée) |
subscription.renewed | une échéance a été débitée |
subscription.past_due | une échéance a échoué, relances en cours |
subscription.unpaid | relances épuisées, abonnement impayé |
subscription.canceled | abonnement résilié |
invoice.finalized | une facture ou un avoir a été émis |
refund.succeeded | un remboursement a été confirmé |
refund.failed | un remboursement a été refusé |
Le corps est un JSON de la forme :
{
"id": "evt_01j9zq…",
"type": "payment.succeeded",
"data": { "id": "pay_01j9zq…", "status": "succeeded", "amount": "58.80", "currency": "EUR",
"customer_id": "cus_…", "mode": "one_time", "tags": { "service": "hosting" }, "…": "…" },
"created_at": "2026-10-08T10:00:00Z"
}
data est l'objet concerné (paiement, débit, abonnement, facture ou remboursement), sous la
même forme que dans l'API. L'identifiant id est aussi envoyé dans l'en-tête
Billing-Event-Id et reste le même si l'envoi est répété : servez-vous-en pour ignorer un
doublon. Un envoi qui ne reçoit pas de réponse 2xx est retenté avec un délai croissant.
3. Vérifier la signature
Chaque appel porte l'en-tête :
Billing-Signature: t=1791450000,v1=5f8a…e21c
t: l'heure de l'envoi, en secondes Unix ;v1:HMAC-SHA256(secret, t + "." + corps)en hexadécimal, calculé sur le corps brut, octet pour octet.
Vérifiez, avant de lire le JSON : que t date de moins de 5 minutes (contre le rejeu),
puis que la signature correspond, avec une comparaison en temps constant.
Go
package billinghook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"net/http"
"strconv"
"strings"
"time"
)
const tolerance = 5 * time.Minute
// Verify contrôle l'en-tête Billing-Signature d'un corps brut.
func Verify(secret []byte, header string, body []byte, now time.Time) error {
var ts string
var sigs []string
for _, part := range strings.Split(header, ",") {
k, v, ok := strings.Cut(strings.TrimSpace(part), "=")
if !ok {
continue
}
switch k {
case "t":
ts = v
case "v1":
sigs = append(sigs, v)
}
}
if ts == "" || len(sigs) == 0 {
return errors.New("en-tête Billing-Signature incomplet")
}
sec, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return errors.New("horodatage invalide")
}
if d := now.Sub(time.Unix(sec, 0)); d > tolerance || d < -tolerance {
return errors.New("horodatage hors tolérance")
}
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(ts + "."))
mac.Write(body)
want := mac.Sum(nil)
for _, s := range sigs {
got, err := hex.DecodeString(s)
if err == nil && hmac.Equal(got, want) { // temps constant
return nil
}
}
return errors.New("signature invalide")
}
// Handler lit le corps brut, le vérifie, puis le confie à process.
func Handler(secret []byte, process func([]byte) error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "lecture", http.StatusBadRequest)
return
}
if err := Verify(secret, r.Header.Get("Billing-Signature"), body, time.Now()); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := process(body); err != nil {
http.Error(w, "traitement", http.StatusInternalServerError) // sera renvoyé
return
}
w.WriteHeader(http.StatusOK)
}
}
Node.js
import crypto from 'node:crypto';
import express from 'express';
const TOLERANCE_S = 5 * 60;
export function verifyBillingSignature(secret, header, rawBody, nowS = Math.floor(Date.now() / 1000)) {
let t;
const sigs = [];
for (const part of (header ?? '').split(',')) {
const i = part.indexOf('=');
if (i < 0) continue;
const k = part.slice(0, i).trim();
const v = part.slice(i + 1).trim();
if (k === 't') t = v;
if (k === 'v1') sigs.push(v);
}
if (!t || sigs.length === 0) throw new Error('en-tête Billing-Signature incomplet');
const ts = Number(t);
if (!Number.isInteger(ts) || Math.abs(nowS - ts) > TOLERANCE_S) throw new Error('horodatage hors tolérance');
const want = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
const ok = sigs.some((s) => {
const got = Buffer.from(s, 'hex');
return got.length === want.length && crypto.timingSafeEqual(got, want); // temps constant
});
if (!ok) throw new Error('signature invalide');
}
const app = express();
// Corps brut obligatoire : express.json() le réécrirait et la signature ne correspondrait plus.
app.post('/hooks/billing', express.raw({ type: 'application/json', limit: '1mb' }), async (req, res) => {
try {
verifyBillingSignature(process.env.BILLING_WEBHOOK_SECRET, req.get('Billing-Signature'), req.body);
} catch (e) {
return res.status(400).send(e.message);
}
const event = JSON.parse(req.body.toString('utf8'));
// Dédoublonner sur event.id avant d'agir (un événement peut être renvoyé).
await handle(event);
res.sendStatus(200);
});
Ce que vous obtenez
Pour chaque événement choisi, un POST signé sur votre adresse. Répondez 2xx dès que
l'événement est enregistré de votre côté.
Ce que vous pouvez en attendre
- Nouvelles tentatives : sans réponse
2xx, l'appel est renvoyé avec un délai croissant, pendant 72 heures au plus. - Même
idà chaque tentative : un même événement peut arriver plusieurs fois. Dédoublonnez suridet rendez votre traitement rejouable. - Pas d'ordre garanti entre deux événements proches : fiez-vous au contenu (le
statusde l'objet), pas à l'ordre d'arrivée. enabled: falsesuspend les envois sans supprimer le webhook.
Si ça ne marche pas
| Ce que vous voyez | Cause | Correction |
|---|---|---|
sng apply refuse l'url | http:// ou adresse non publique | une adresse https:// publique |
sng apply refuse un événement | nom mal orthographié | reprendre la liste ci-dessus |
| Signature toujours invalide | corps relu ou réencodé avant la vérification (express.json(), middleware…), mauvais secret, ou espace/saut de ligne autour du secret | vérifier sur le corps brut ; relire le secret avec sng secret get |
| « horodatage hors tolérance » | horloge de votre serveur décalée | synchroniser l'horloge (NTP) |
| Le même événement arrive plusieurs fois | votre adresse a répondu trop tard ou en erreur | répondre 2xx vite ; dédoublonner sur id |
| Aucun appel reçu | événement absent de events, enabled: false, ou adresse injoignable depuis Internet | vérifier le manifeste et l'accessibilité de l'adresse |
Voir aussi Dépannage des paiements.