Aller au contenu

Webhooks

Définissez callbackUrl sur un paiement et Stablezact y enverra des requêtes POST au fil de la progression du paiement. C’est le moyen fiable de savoir qu’un paiement est réglé, car il ne dépend pas du fait que le client garde un onglet de navigateur ouvert.

config={{
amount: 49.99,
currency: 'USDC',
callbackUrl: 'https://yourstore.com/webhooks/stablezact',
orderId: 'ORD-1234',
metadata: { basket: 'b-99' },
}}

orderId et metadata sont tous deux renvoyés dans chaque événement : c’est ainsi que vous associez un événement à une commande sans tenir votre propre table de correspondance.

Lisez type. Il utilise le même vocabulaire réduit, quelle que soit la façon dont le client a payé.

type Signification Honorer
payment.pending des fonds ont été détectés, le paiement n’est pas encore final non
payment.succeeded le client a payé oui
payment.underpaid le montant reçu est inférieur au montant facturé non, relancez le client
payment.failed expiré, annulé ou échoué non
payment.refunded les fonds ont été rendus au client annulez la commande
settlement.completed votre versement est arrivé sur votre wallet déjà honorée
settlement.delayed votre versement est plus lent que d’habitude, nouvelles tentatives automatiques rien à faire
settlement.failed votre versement n’a pas pu être effectué, nous vous contactons rien à faire
webhook.test une livraison de test non

payment.succeeded est le seul qui signifie d’honorer la commande. Tous les autres sont là si vous en avez besoin.

Les événements payment répondent à la question « dois-je expédier cette commande ». Les événements settlement répondent à « où est mon argent », et ils ne changent jamais la décision d’expédier. Beaucoup d’intégrations les ignorent et font leur rapprochement à partir de leur wallet.

type est aussi envoyé dans l’en-tête X-Webhook-Type, si vous préférez router sur un en-tête plutôt que d’analyser le corps.

Chaque payload contient aussi un champ event, antérieur à type. Il utilise deux conventions de nommage, donne deux noms différents au même résultat selon la façon dont le client a payé, et nomme nos étapes internes de règlement. Il fonctionne toujours et continuera de fonctionner : les intégrations existantes n’ont pas besoin d’être modifiées.

Construisez les nouvelles intégrations sur type. Si vous lisez une intégration plus ancienne, voici la correspondance entre les deux :

event type
payment_success, deposit.confirmed payment.succeeded
payment_failure, deposit.failed, bridge.failed payment.failed
deposit.detected, bridge.filled payment.pending
deposit.swept, settlement.completed settlement.completed
settlement.delayed settlement.delayed
payment.underpaid payment.underpaid
payment.refunded payment.refunded
webhook_test webhook.test

Le paiement est paymentId dans chaque payload. Certains anciens événements l’appellent aussi transactionId ; les deux sont présents et identiques. La transaction de versement est settlementTxHash, également présente sous le nom sweepTxHash dans les anciens événements.

Si vous ne savez pas quel événement vous avez sous les yeux, récupérez le paiement et lisez outcome. C’est la même réponse, et elle ne dépend pas des événements qui vous sont parvenus.

Chaque livraison comporte deux en-têtes :

X-Webhook-Signature: sha256=<hex digest>
X-Webhook-Timestamp: <milliseconds since epoch>

Effectuez la vérification avant d’agir sur le corps. Un endpoint qui honore des commandes sur un POST non vérifié honorera des commandes pour quiconque trouve l’URL.

Le SDK fournit la vérification, vous n’avez donc pas à la réimplémenter :

import { verifyWebhookSignature } from 'stablezact-pay';
app.post('/webhooks/stablezact', express.json(), (req, res) => {
const result = verifyWebhookSignature(
req.body,
req.headers['x-webhook-signature'],
req.headers['x-webhook-timestamp'],
process.env.STABLEZACT_WEBHOOK_SECRET
);
if (!result.valid) return res.status(400).json({ error: result.error });
res.sendStatus(200);
handleEvent(req.body);
});

Il existe aussi coinleyWebhookMiddleware si vous préférez le monter en tant que middleware.

import crypto from 'node:crypto';
function verify(payload, signatureHeader, timestampHeader, secret) {
const ts = parseInt(timestampHeader, 10);
// Reject anything older than five minutes, so a captured delivery cannot be
// replayed later.
if (Date.now() - ts > 5 * 60 * 1000) return false;
const withTimestamp = { ...payload, timestamp: ts };
const serialized = JSON.stringify(withTimestamp, Object.keys(withTimestamp).sort());
const expected = crypto.createHmac('sha256', secret).update(serialized).digest('hex');
const received = signatureHeader.replace(/^sha256=/, '');
const a = Buffer.from(received, 'hex');
const b = Buffer.from(expected, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Trois détails qui comptent :

Le timestamp fait partie des données signées : il doit donc être ajouté au payload avant la sérialisation, sous forme de nombre.

Les clés sont triées. JSON.stringify(value, Object.keys(value).sort()) est exactement l’appel que fait le serveur.

Votre secret webhook doit comporter au moins 32 caractères. Les secrets plus courts sont rejetés par le vérificateur, plutôt que d’affaiblir silencieusement la signature.

Répondez 200 rapidement et faites le travail ensuite. Un endpoint lent ressemble à un endpoint en échec.

Gérez le cas où le même événement arrive deux fois. Les nouvelles tentatives et les délais d’attente réseau impliquent une livraison au moins une fois : utiliser comme clé paymentId plus event et ignorer les répétitions vaut bien les quelques lignes que cela demande.