paytech-node-ts-sdk
v2.0.0
Published
SDK TypeScript pour PayTech : paiement, statut, remboursement et vérification des notifications IPN. Zéro dépendance.
Maintainers
Readme
paytech-node-ts-sdk
SDK TypeScript pour PayTech — l'agrégateur sénégalais qui couvre Wave, Orange Money, Free Money, Wizall et carte bancaire en une seule intégration.
Paiement, statut, remboursement, et surtout vérification cryptographique des notifications IPN — l'étape que la plupart des intégrations oublient, et sans laquelle n'importe qui peut déclarer une commande payée.
Aucune dépendance de production. Node ≥ 18.
npm install paytech-node-ts-sdkCréer un paiement
import { PayTech } from "paytech-node-ts-sdk";
const paytech = new PayTech({
apiKey: process.env.PAYTECH_API_KEY!,
apiSecret: process.env.PAYTECH_API_SECRET!,
});
const payment = await paytech.createPayment({
item_name: "Formation niveau avancé",
item_price: 14900, // un nombre, pas une chaîne
currency: "XOF",
env: "test", // "test" | "prod"
ipn_url: "https://mon-site.sn/api/paytech/ipn",
success_url: "https://mon-site.sn/merci",
cancel_url: "https://mon-site.sn/panier",
custom_field: { userId: 42 }, // te reviendra dans l'IPN
target_payment: ["Wave", "Orange Money"],
});
// PayTech répond success: 1 ou 0 — jamais un booléen.
if (payment.success === 1) {
redirect(payment.redirect_url!);
}ref_command est généré automatiquement si tu n'en fournis pas, et toujours
renvoyé dans la réponse. Conserve aussi payment.token : c'est lui, et non
la référence, qui permet d'interroger le statut.
Vérifier une notification IPN
C'est la partie critique. PayTech POSTe sur ton ipn_url pour t'annoncer l'issue
du paiement — mais l'URL est publique. Sans vérification, n'importe qui peut
t'envoyer un faux sale_complete et faire livrer une commande jamais payée.
import express from "express";
import { verifyIpn, decodeCustomField, isPaymentComplete } from "paytech-node-ts-sdk";
app.post("/api/paytech/ipn", express.json(), async (req, res) => {
const config = {
apiKey: process.env.PAYTECH_API_KEY!,
apiSecret: process.env.PAYTECH_API_SECRET!,
};
// 1. Authenticité de l'émetteur
if (!verifyIpn(req.body, config)) {
return res.sendStatus(401);
}
// 2. Répondre vite : PayTech réessaie si tu tardes
res.sendStatus(200);
if (!isPaymentComplete(req.body)) return;
// 3. Contrôles métier — le SDK ne peut pas les faire à ta place
const order = await findOrder(req.body.ref_command);
if (!order) return;
if (order.status === "paid") return; // idempotence
if (Number(req.body.final_item_price) < order.amount) return; // montant
const { userId } = decodeCustomField<{ userId: number }>(req.body.custom_field) ?? {};
await markOrderPaid(order.id, { userId, method: req.body.payment_method });
});verifyIpn s'appuie sur le HMAC-SHA256 calculé par PayTech : des deux méthodes
documentées, c'est la seule qui lie la preuve au contenu du message. Une
notification dépourvue de hmac_compute est refusée par défaut. Les
comparaisons sont faites en temps constant.
À calibrer une fois. La documentation PayTech écrit le message HMAC « amount|ref_command|api_key » sans préciser quel montant. Le SDK utilise
final_item_pricepar défaut. Si tes vérifications échouent systématiquement alors que les notifications sont légitimes, journalise une charge utile réelle et ajuste :verifyIpn(req.body, config, { amountField: "item_price" });Notifications sans HMAC. Si ton compte PayTech en reçoit réellement, le repli historique sur les empreintes SHA-256 des identifiants se réactive explicitement :
verifyIpn(req.body, config, { allowUnsignedLegacyHashes: true });⚠️ Ce repli n'authentifie pas le contenu de la notification.
api_key_sha256etapi_secret_sha256sont deux constantes, identiques sur toutes les notifications de ton compte : elles ne sont liées ni au montant, ni àref_command, ni àtype_event. Une seule notification légitime qui fuite — journaux applicatifs, observabilité, capture de debug — livre ces deux empreintes de façon définitive, jusqu'à rotation de tes clés PayTech. Un attaquant peut alors forger unsale_completeportant la référence et le montant exacts d'une de ses propres commandes impayées : ni le contrôle du montant ni l'idempotence ne l'arrêtent, puisque les deux valeurs sont authentiques. Traite ce repli comme transitoire — le SDK émet un avertissement NodePAYTECH_IPN_UNSIGNED_FALLBACKla première fois qu'il authentifie une notification par ce biais.
Statut et remboursement
const status = await paytech.getStatus(payment.token!); // le jeton, pas la référence
const refund = await paytech.refundPayment("CMD-1750000000000-abcd1234");Gestion des erreurs
Toute réponse HTTP non-2xx lève une PayTechError portant le statut et le corps
brut de la réponse. Les appels réseau expirent au bout de 15 secondes par défaut.
import { PayTechError } from "paytech-node-ts-sdk";
try {
await paytech.getStatus(token);
} catch (err) {
if (err instanceof PayTechError) {
console.error(err.status, err.body);
}
}Options du constructeur : baseUrl (pour pointer vers un mock en test) et
timeoutMs.
Les URL de retour et de notification
Trois URL se transmettent à createPayment, et elles n'obéissent pas aux mêmes
règles.
ipn_url — HTTPS obligatoire, vérifié ici. PayTech rejette une adresse en
HTTP (ipn_url doit etre en https). Le SDK le vérifie avant l'appel réseau
pour vous éviter l'aller-retour :
await paytech.createPayment({ item_name: "Cotisation", item_price: 2000, ipn_url: "http://localhost:4000/ipn" });
// Error: PayTech : ipn_url doit être en HTTPS. Reçu : http://localhost:4000/ipnsuccess_url et cancel_url — publiques en HTTPS, mais non vérifiées.
L'API les accepte en local sans broncher : createPayment réussit et renvoie
une URL de redirection. C'est la page de paiement qui refuse, au moment où
l'acheteur valide, avec ce message :
Erreur — successRedirectUrl doit être un URL
Le nom du champ est celui de PayTech, pas le nôtre : cherchez success_url
dans votre code, pas successRedirectUrl. Le SDK ne les rejette pas — l'API
les accepte, et lui interdire ici serait contredire le service qu'il enveloppe.
En développement, un tunnel (cloudflared tunnel --url http://localhost:3000)
donne une adresse publique pour les trois.
Migration depuis la 1.2.x
Un seul changement, et il concerne la sécurité. verifyIpn refuse désormais
par défaut les notifications dépourvues de hmac_compute, au lieu de retomber
silencieusement sur la comparaison des empreintes SHA-256 des identifiants. Ce
repli ne liant la preuve à aucun champ de la charge utile, il ne pouvait pas
rester le comportement par défaut ; le détail du risque est expliqué dans
Vérifier une notification IPN.
| 1.2.0 | 2.0.0 |
|---|---|
| verifyIpn(payload, config) accepte une notification non signée | la refuse |
| requireHmac?: boolean | allowUnsignedLegacyHashes?: boolean (requireHmac déprécié, toujours accepté) |
| repli silencieux | avertissement Node PAYTECH_IPN_UNSIGNED_FALLBACK à la première acceptation |
Si tu passais déjà une option, tu n'as rien à faire : requireHmac: true et
requireHmac: false gardent exactement le comportement de la 1.x — la seconde
reste un opt-in explicite au repli. Seul l'appel sans option change de
comportement. requireHmac reste accepté sans échéance de retrait ; fournir les
deux options de façon contradictoire lève une TypeError au lieu d'appliquer
une règle de précédence implicite.
Comment savoir si tu es concerné. Si tes notifications portent
hmac_compute — c'est le cas courant — la mise à jour est transparente. Sinon,
tes vérifications se mettront à échouer : journalise une charge utile réelle
pour le confirmer, puis choisis entre demander l'activation du HMAC à PayTech
(recommandé) et réactiver le repli le temps de la bascule :
verifyIpn(req.body, config, { allowUnsignedLegacyHashes: true });Le reste de l'API est inchangé.
Migration depuis la 1.1.x
Le paquet est désormais publié en CommonJS et en ESM. La 1.1.0 était
ESM seule : require() n'y fonctionnait qu'à partir de Node 22.12, et Jest,
qui tourne en CommonJS, ne savait pas la lire sans transpilation
(transformIgnorePatterns). Ces deux contournements ne sont plus nécessaires.
| 1.1.0 | 1.2.0 |
|---|---|
| ESM seul | import et require |
| Node ≥ 22.12 pour require() | Node ≥ 18 |
| transformIgnorePatterns requis dans Jest | rien à configurer |
| ipn_url en HTTP : échec après l'appel réseau | échec immédiat, message explicite |
Aucun changement d'API : les signatures sont identiques.
Migration depuis la 1.0.x
La 1.0.7 était inutilisable — un import échouait sur ERR_MODULE_NOT_FOUND —
et embarquait du code applicatif. Ce qui change :
| 1.0.x | 1.1.0 |
|---|---|
| verifyPayment(ref_command) | getStatus(token) — l'endpoint précédent n'existait pas |
| item_price: string | item_price: number |
| success: boolean | success: 0 \| 1 |
| env obligatoire | optionnel, "prod" par défaut |
| dépend de pg et dotenv | zéro dépendance |
| import { PayTech } from "paytech-node-ts-sdk/dist/paytech.js" | import { PayTech } from "paytech-node-ts-sdk" |
| aucune vérification d'IPN | verifyIpn, decodeCustomField, isPaymentComplete |
Le code applicatif retiré (couche PostgreSQL, service métier) est conservé à
titre d'exemple dans examples/paydev/, hors du paquet
publié.
Côté navigateur
N'appelle jamais PayTech depuis le front : tes clés se retrouveraient dans le
bundle JavaScript, et PayTech désactive de toute façon le CORS sur les routes
authentifiées. Pour React et Next.js, utilise
paytech-react-hooks, qui
appelle une route de ton backend.
Licence
MIT © Adramé Diakhaté
