Appearance
Intégrer HayBTech sur votre site
Ce guide vous montre comment encaisser Wave et Orange Money sur votre site en trois étapes :
- Côté serveur créer une transaction (une requête signée avec votre clé secrète).
- Côté navigateur ouvrir le popup de paiement HayBTech avec le SDK JS.
- Côté serveur recevoir et vérifier le webhook final.
Architecture recommandée : le popup JS ne parle jamais directement à HayBTech avec votre clé secrète. Il appelle une route de votre backend, qui elle appelle HayBTech. C'est la seule façon de ne pas exposer
pk_live_dans le navigateur.
Vue d'ensemble du flux
Navigateur Votre backend HayBTech
clic "Payer"
POST /v1/payments
payment_url, id
JSON { paymentUrl }
popup HayBTech (choix Wave / OM)
webhook signé
payment.success
redirection success_url / callback JS1. Pré-requis
- Un compte marchand sur https://app.haybtech.com (KYC validé pour la prod).
- Vos clés API depuis Développeurs Clés API :
pk_test_/sk_test_pour les tests d'intégration (provider simulé, aucun débit réel)pk_live_/sk_live_pour la production
- Un endpoint HTTPS public pour le webhook (le mode test accepte ngrok / cloudflared).
Ne jamais exposer pk_live_ côté navigateur
Toute clé API HayBTech est secrète. Le popup JS n'a jamais besoin de la clé : il appelle votre backend, qui signe la requête serveur-à-serveur.
2. Backend créer une transaction
Le backend expose une seule route interne (par ex. POST /checkout/haybtech) que le popup appelle. Cette route :
- Vérifie l'utilisateur / le panier.
- Appelle
POST https://app.haybtech.com/v1/payments. - Renvoie au navigateur
payment_urletid.
Endpoint HayBTech
| Méthode | URL |
|---|---|
POST | https://app.haybtech.com/v1/payments |
Headers :
| Header | Valeur |
|---|---|
Authorization | Bearer pk_test_ ou pk_live_ |
Content-Type | application/json |
Body :
| Champ | Type | Obligatoire | Description |
|---|---|---|---|
merchant_ref | string | oui | Référence unique côté marchand (numéro de commande). |
amount | int | oui | Montant en unités entières (ex. 5000 = 5 000 XOF). |
currency | string | non | XOF par défaut. |
provider | string | non | wave, orange_money. Omettre pour laisser le payeur choisir dans le popup (Hosted Checkout). |
payer.phone | string | recommandé | Format E.164 (+221). Pré-remplit le popup. |
payer.name | string | non | Nom affiché sur le reçu. |
success_url | string | oui | URL de retour après succès (page de remerciement). |
failed_url | string | oui | URL de retour en cas d'annulation. |
metadata | object | non | Jusqu'à 20 clés libres, renvoyées dans le webhook. |
expires_in | int (s) | non | Durée de validité du payment_url. Défaut : 30 min. |
Exemples
php
// routes/web.php ou routes/api.php
Route::post('/checkout/haybtech', function (Request $request) {
$order = Order::findOrFail($request->integer('order_id'));
$response = Http::withToken(config('services.haybtech.key'))
->acceptJson()
->post('https://app.haybtech.com/v1/payments', [
'merchant_ref' => $order->reference,
'amount' => $order->total_xof,
'currency' => 'XOF',
'payer' => [
'phone' => $order->customer_phone,
'name' => $order->customer_name,
],
'success_url' => route('checkout.success', $order),
'failed_url' => route('checkout.cancel', $order),
'metadata' => [
'order_id' => $order->id,
'user_id' => $request->user()->id,
],
])
->throw()
->json('data');
return response()->json([
'id' => $response['id'],
'paymentUrl' => $response['payment_url'],
]);
})->middleware('auth');ts
import express from 'express'
const app = express()
app.use(express.json())
app.post('/checkout/haybtech', async (req, res) => {
const order = await db.orders.findById(req.body.order_id)
const r = await fetch('https://app.haybtech.com/v1/payments', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.HAYBTECH_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
merchant_ref: order.reference,
amount: order.total_xof,
currency: 'XOF',
payer: { phone: order.customerPhone, name: order.customerName },
success_url: `${process.env.APP_URL}/checkout/success?id=${order.id}`,
failed_url: `${process.env.APP_URL}/checkout/cancel?id=${order.id}`,
metadata: { order_id: order.id, user_id: req.user.id },
}),
})
if (!r.ok) return res.status(502).json({ error: await r.text() })
const { data } = await r.json()
res.json({ id: data.id, paymentUrl: data.payment_url })
})python
import os, httpx
from fastapi import APIRouter, Depends, HTTPException
router = APIRouter()
@router.post("/checkout/haybtech")
async def checkout(order_id: int, user = Depends(auth)):
order = await db.orders.get(order_id)
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(
"https://app.haybtech.com/v1/payments",
headers={
"Authorization": f"Bearer {os.environ['HAYBTECH_API_KEY']}",
},
json={
"merchant_ref": order.reference,
"amount": order.total_xof,
"currency": "XOF",
"payer": {"phone": order.customer_phone, "name": order.customer_name},
"success_url": f"{APP_URL}/checkout/success?id={order.id}",
"failed_url": f"{APP_URL}/checkout/cancel?id={order.id}",
"metadata": {"order_id": order.id, "user_id": user.id},
},
)
if r.status_code >= 400:
raise HTTPException(502, r.text)
data = r.json()["data"]
return {"id": data["id"], "paymentUrl": data["payment_url"]}Réponse HayBTech
json
{
"data": {
"id": "0e85b3c2-4a01-4e8f-b8b8-b71e0e1c8d1a",
"object": "transaction",
"reference": "TXN-A1B2C3D4E5",
"status": "pending",
"amount": "5000.00",
"currency": "XOF",
"payment_url": "https://app.haybtech.com/pay/checkout/0e85b3c2-...",
"expires_at": "2026-04-15T12:30:00+00:00"
},
"livemode": false
}Votre backend doit persister data.id et data.reference et les lier à votre order_id avant de répondre au navigateur. C'est cette table qui sert ensuite à rapprocher le webhook.
3. Frontend le popup HayBTech
Le SDK JS est un script sans dépendance ( 12 KB gzip). Il ouvre le payment_url dans un popup modal, écoute les événements de paiement et vous rappelle quand c'est terminé.
Inclusion
html
<link rel="stylesheet" href="https://cdn.haybtech.com/v1/haybtech.min.css">
<script src="https://cdn.haybtech.com/v1/haybtech.min.js" defer></script>Servi depuis un CDN signé (SRI disponible dans la release notes). Le fichier est immutable par version ; utilisez
v1pour suivre les correctifs, ou épinglez une version précise (v1.4.2) si vous préférez.
Intégration simple popup
html
<button id="pay-btn" data-order-id="42">
Payer 5 000 XOF
</button>
<script>
document.getElementById('pay-btn').addEventListener('click', async (e) => {
const orderId = e.currentTarget.dataset.orderId;
// 1) Demander à VOTRE backend de créer la transaction HayBTech.
const res = await fetch('/checkout/haybtech', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order_id: orderId }),
});
const { id, paymentUrl } = await res.json();
// 2) Ouvrir le popup sur l'URL retournée par HayBTech.
new HayBTech({ transactionId: id, paymentUrl })
.withOption({
presentationMode: HayBTech.OPEN_IN_POPUP,
locale: 'fr',
didReceivePayment: (event) => {
// event.status === 'success' | 'failed' | 'cancelled'
if (event.status === 'success') {
window.location.href = '/checkout/success?id=' + orderId;
}
},
didCancel: () => console.info('Paiement annulé par le payeur'),
didReceiveError: (err) => console.error('[HayBTech]', err),
})
.send();
});
</script>Intégration déclarative attributs data-*
Pour un site statique ou un CMS, pas besoin de JS custom :
html
<button
data-haybtech
data-haybtech-request-url="/checkout/haybtech"
data-haybtech-body='{"order_id": 42}'
data-haybtech-success-url="/checkout/success"
data-haybtech-mode="popup">
Payer
</button>Le SDK auto-scanne [data-haybtech] au chargement, appelle l'URL indiquée en POST, lit paymentUrl dans la réponse JSON et ouvre le popup.
Modes de présentation
| Constante | Comportement |
|---|---|
HayBTech.OPEN_IN_POPUP | Modal centré au-dessus de la page (par défaut). |
HayBTech.OPEN_IN_NEW_TAB | Ouvre un nouvel onglet (fallback si popups bloqués). |
HayBTech.OPEN_IN_SAME_TAB | Redirection pleine page vers payment_url. |
HayBTech.EMBED | Inline dans un <div id="haybtech-frame"></div> fourni. |
HayBTech.HEADLESS | N'ouvre rien, renvoie paymentUrl à vous d'afficher. |
Callbacks
| Option | Signature | Quand |
|---|---|---|
didReceivePayment | ({ status, transactionId, reference }) | Le popup a reçu un événement terminal. |
didCancel | () | Le payeur a fermé le popup ou cliqué "annuler". |
didReceiveError | (error) | Erreur réseau, token expiré, CSP |
didClose | () | Toujours appelé à la fermeture (après les autres). |
Événement autorité
Les callbacks JS sont indicatifs (l'utilisateur peut fermer l'onglet trop tôt, couper le réseau, etc.). Ne marquez jamais une commande "payée" depuis le navigateur seul le webhook fait foi.
Vue.js / Inertia
vue
<script setup lang="ts">
import { ref } from 'vue'
const loading = ref(false)
async function pay(orderId: number) {
loading.value = true
const { id, paymentUrl } = await fetch('/checkout/haybtech', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf() },
body: JSON.stringify({ order_id: orderId }),
}).then(r => r.json())
new window.HayBTech({ transactionId: id, paymentUrl })
.withOption({
presentationMode: window.HayBTech.OPEN_IN_POPUP,
didReceivePayment: () => router.visit(`/orders/${orderId}`),
didClose: () => (loading.value = false),
})
.send()
}
</script>
<template>
<button :disabled="loading" @click="pay(42)">Payer</button>
</template>React
tsx
import { useState } from 'react'
declare global { interface Window { HayBTech: any } }
export function PayButton({ orderId }: { orderId: number }) {
const [loading, setLoading] = useState(false)
async function pay() {
setLoading(true)
const { id, paymentUrl } = await fetch('/checkout/haybtech', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order_id: orderId }),
}).then(r => r.json())
new window.HayBTech({ transactionId: id, paymentUrl })
.withOption({
presentationMode: window.HayBTech.OPEN_IN_POPUP,
didReceivePayment: e => e.status === 'success' && (location.href = '/success'),
didClose: () => setLoading(false),
})
.send()
}
return <button disabled={loading} onClick={pay}>Payer</button>
}4. Webhook la seule source de vérité
HayBTech POST votre endpoint dès que le statut change. La requête est signée avec le header X-HayB-Signature.
php
Route::post('/webhooks/haybtech', function (Request $request) {
$header = $request->header('X-HayB-Signature', '');
$body = $request->getContent();
$secret = config('services.haybtech.webhook_secret');
if (! preg_match('/^t=(\d+),v1=([a-f0-9]{64})/', $header, $m)) {
abort(400, 'bad signature');
}
if (abs(time() - (int) $m[1]) > 300) {
abort(401, 'expired');
}
$expected = hash_hmac('sha256', $m[1] . '.' . $body, $secret);
if (! hash_equals($expected, $m[2])) {
abort(401, 'invalid');
}
$event = json_decode($body, true);
match ($event['event']) {
'payment.success' => Order::markPaid($event['data']['merchant_ref']),
'payment.failed' => Order::markFailed($event['data']['merchant_ref']),
'payment.cancelled' => Order::markCancelled($event['data']['merchant_ref']),
default => null,
};
return response()->json(['received' => true]);
});Idempotence
Chaque événement est livré au moins une fois. Utilisez event.id (UUID) comme clé d'unicité dans votre base : si vous l'avez déjà traité, répondez 200 sans rien faire.
Détails complets, rotation de secret et format multi-version : voir Webhooks.
5. Mode test vs production
| Mode test | Production | |
|---|---|---|
| Clé | pk_test_ / sk_test_ | pk_live_ / sk_live_ |
| Base URL | https://app.haybtech.com/v1 | identique |
| Connecteurs | Simulés (boutons Simuler succès / échec) | Réels, KYC requis |
| Montant débité | Aucun | Exact |
livemode dans les réponses | false | true |
Le mode est déterminé par la clé, pas par l'URL : vos clés de test routent automatiquement vers le simulateur côté serveur.
Pour forcer un état en mode test, ouvrez payment_url dans le popup puis cliquez sur le bouton correspondant (Simuler succès / échec / annulation). Le webhook est déclenché comme en prod.
6. Checklist avant mise en production
- [ ] Le backend stocke
transaction.idetmerchant_refavant de répondre au navigateur. - [ ] Le popup ne décide jamais seul du statut de la commande le webhook est la source de vérité.
- [ ] L'endpoint webhook vérifie
t,v1, la fenêtre de 5 min et répond2xxen < 5 s. - [ ]
success_urletfailed_urlaffichent le statut réel (depuis votre DB, alimentée par le webhook), pas une confirmation optimiste. - [ ] Les logs ne stockent jamais la clé API en clair.
- [ ] La CSP autorise
https://cdn.haybtech.com(script-src) ethttps://app.haybtech.com(frame-src, connect-src) si vous utilisez le popup. - [ ] Test complet en mode test (clés
sk_test_…) : succès, échec, annulation, expiration, double webhook.
Voir aussi la checklist d'intégration détaillée et Test Production.
7. Questions fréquentes
Le popup est-il obligatoire ? Non. Vous pouvez rediriger directement vers payment_url (window.location.href = paymentUrl). Le popup offre une meilleure UX parce que le payeur ne quitte pas votre page.
Puis-je appeler /v1/payments directement depuis le navigateur ? Non, jamais. Cela exposerait votre clé API. La route interne /checkout/haybtech est la seule façon sûre.
Combien de temps un payment_url est-il valide ? 30 minutes par défaut, configurable par expires_in à la création. Après expiration, HayBTech envoie payment.expired.
Puis-je avoir plusieurs paiements simultanés pour une même commande ? Oui, mais évitez-le. Utilisez merchant_ref unique par tentative (ex. ORDER-42-v2) pour garder un lien 1:1 côté réconciliation.
