Skip to content

Intégrer HayBTech sur votre site

Ce guide vous montre comment encaisser Wave et Orange Money sur votre site en trois étapes :

  1. Côté serveur créer une transaction (une requête signée avec votre clé secrète).
  2. Côté navigateur ouvrir le popup de paiement HayBTech avec le SDK JS.
  3. 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 JS

1. 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 :

  1. Vérifie l'utilisateur / le panier.
  2. Appelle POST https://app.haybtech.com/v1/payments.
  3. Renvoie au navigateur payment_url et id.

Endpoint HayBTech

MéthodeURL
POSThttps://app.haybtech.com/v1/payments

Headers :

HeaderValeur
AuthorizationBearer pk_test_ ou pk_live_
Content-Typeapplication/json

Body :

ChampTypeObligatoireDescription
merchant_refstringouiRéférence unique côté marchand (numéro de commande).
amountintouiMontant en unités entières (ex. 5000 = 5 000 XOF).
currencystringnonXOF par défaut.
providerstringnonwave, orange_money. Omettre pour laisser le payeur choisir dans le popup (Hosted Checkout).
payer.phonestringrecommandéFormat E.164 (+221). Pré-remplit le popup.
payer.namestringnonNom affiché sur le reçu.
success_urlstringouiURL de retour après succès (page de remerciement).
failed_urlstringouiURL de retour en cas d'annulation.
metadataobjectnonJusqu'à 20 clés libres, renvoyées dans le webhook.
expires_inint (s)nonDuré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 v1 pour 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

ConstanteComportement
HayBTech.OPEN_IN_POPUPModal centré au-dessus de la page (par défaut).
HayBTech.OPEN_IN_NEW_TABOuvre un nouvel onglet (fallback si popups bloqués).
HayBTech.OPEN_IN_SAME_TABRedirection pleine page vers payment_url.
HayBTech.EMBEDInline dans un <div id="haybtech-frame"></div> fourni.
HayBTech.HEADLESSN'ouvre rien, renvoie paymentUrl à vous d'afficher.

Callbacks

OptionSignatureQuand
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 testProduction
Clépk_test_ / sk_test_pk_live_ / sk_live_
Base URLhttps://app.haybtech.com/v1identique
ConnecteursSimulés (boutons Simuler succès / échec)Réels, KYC requis
Montant débitéAucunExact
livemode dans les réponsesfalsetrue

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.id et merchant_ref avant 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épond 2xx en < 5 s.
  • [ ] success_url et failed_url affichent 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) et https://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.

Documentation de l'API de paiement HayBTech.