Aller au contenu principal

Webhooks de paiement

Bêta

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 accepte POST.

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​

billing/webhook.yml
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énementQuand
payment.succeededun panier a été payé
payment.failedun paiement sur la page de paiement a échoué
charge.succeededun débit à l'usage a réussi
charge.action_requiredun débit demande une authentification forte de votre client (action_url)
charge.failedun débit a été refusé
subscription.createdun abonnement a démarré (première échéance payée)
subscription.renewedune échéance a été débitée
subscription.past_dueune échéance a échoué, relances en cours
subscription.unpaidrelances épuisées, abonnement impayé
subscription.canceledabonnement résilié
invoice.finalizedune facture ou un avoir a été émis
refund.succeededun remboursement a été confirmé
refund.failedun 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 sur id et rendez votre traitement rejouable.
  • Pas d'ordre garanti entre deux événements proches : fiez-vous au contenu (le status de l'objet), pas à l'ordre d'arrivée.
  • enabled: false suspend les envois sans supprimer le webhook.

Si ça ne marche pas​

Ce que vous voyezCauseCorrection
sng apply refuse l'urlhttp:// ou adresse non publiqueune adresse https:// publique
sng apply refuse un événementnom mal orthographiéreprendre la liste ci-dessus
Signature toujours invalidecorps relu ou réencodé avant la vérification (express.json(), middleware…), mauvais secret, ou espace/saut de ligne autour du secretvérifier sur le corps brut ; relire le secret avec sng secret get
« horodatage hors tolérance »horloge de votre serveur décaléesynchroniser l'horloge (NTP)
Le même événement arrive plusieurs foisvotre adresse a répondu trop tard ou en erreurrépondre 2xx vite ; dédoublonner sur id
Aucun appel reçuévénement absent de events, enabled: false, ou adresse injoignable depuis Internetvérifier le manifeste et l'accessibilité de l'adresse

Voir aussi Dépannage des paiements.

Voir aussi​