FixPay — Guide d'intégration de l'API B2B Marchand#
Version : rédigée à partir du code du backend Laravel FixPay. Tout élément absent du code est signalé explicitement par la mention « non présent dans le code » ou « ambigu ». Ne vous fiez à aucune valeur non citée ici.
1. Introduction#
L'API B2B FixPay permet à un marchand d'émettre et de gérer des cartes virtuelles libellées en USD par des appels serveur-à-serveur, authentifiés par signature HMAC-SHA256.
Six opérations sont exposées au marchand :
| Opération | Méthode + chemin |
|---|---|
| Émettre une carte | POST /api/b2b/cards |
| Consulter une carte | GET /api/b2b/cards/{uuid} |
| Révéler les données sensibles (PAN/CVV) | POST /api/b2b/cards/{uuid}/reveal |
| Geler une carte | POST /api/b2b/cards/{uuid}/freeze |
| Débloquer une carte | POST /api/b2b/cards/{uuid}/unfreeze |
| Annuler une carte | POST /api/b2b/cards/{uuid}/cancel |
Ces appels sont strictement serveur-à-serveur : ils exigent votre secret HMAC, qui ne doit jamais transiter côté client (navigateur, application mobile). Toute la surface marchand se limite à ces 6 endpoints ; il n'existe aucun endpoint de self-service (création de compte, gestion de clés, approvisionnement du wallet) — ces opérations sont réalisées par l'équipe FixPay (voir §2).
Points structurants à retenir dès maintenant :
- La carte est libellée en USD, mais votre wallet est débité en XOF (modèle prépayé). Vous devez approvisionner votre wallet XOF avant d'émettre.
- L'émission est asynchrone côté fournisseur : la réponse
201confirme la prise en compte de l'ordre, pas nécessairement la disponibilité immédiate de la carte (voir §5.1).
2. Prise en main / prérequis#
Toutes les opérations d'approvisionnement décrites ici sont réalisées par l'administrateur FixPay, pas par vous. Elles sont listées pour que vous compreniez ce que vous recevez.
2.1 Obtention des identifiants#
Lors de l'émission d'une clé API par FixPay, vous recevez une seule fois :
public_key— préfixe littéralpk_suivi de 32 caractères hexadécimaux minuscules (ex.pk_ab12...). Elle est transmise en clair dans l'en-têteX-FixPay-Key. Source :ApiKeyService.php:26-28.secret— 64 caractères hexadécimaux. C'est la clé HMAC utilisée pour signer vos requêtes. Source :ApiKeyService.php:26-28.webhook_secret— 64 caractères hexadécimaux. Sert uniquement à vérifier la signature des webhooks entrants que FixPay vous enverra (voir §9). Source :ApiKeyService.php:26-28.
Le
secretet lewebhook_secretne sont affichés qu'une seule fois, au moment de l'émission de la clé (réponse admin, en-têtesCache-Control: no-store). FixPay les stocke chiffrés au repos (AES-256-GCM) et ne peut pas les ré-afficher. Stockez-les immédiatement dans un coffre. La seule façon d'en obtenir de nouveaux est d'émettre une nouvelle clé. Source :B2bMerchantController.php:107-116,ApiKeyService.php:35-37,B2bApiKeyResource.php:21-29.
Rotation / révocation. La rotation se fait en deux temps côté FixPay : émettre
une nouvelle clé, puis révoquer l'ancienne. Plusieurs clés active peuvent coexister
pour un même marchand. Une clé révoquée ou expirée entraîne un 401 sur tous vos
appels. Source : ApiKeyService.php:53-80, VerifyB2bHmac.php:39-46.
2.2 Approvisionnement du wallet (modèle prépayé)#
L'émission de carte débite votre wallet marchand en XOF. Vous devez donc être pré-financé avant d'émettre.
- Le wallet est un compte de grand livre (ledger) en XOF — pas une simple
colonne de solde. Source :
InitiateB2bCardIssuance.php:87-97,B2bMerchantResource.php:34,45. - L'approvisionnement est effectué par FixPay (double contrôle / approbation) en XOF
uniquement. Source :
CreditMerchantWallet.php:40-42,CreditMerchantWalletRequest.php:24. - À l'émission, le montant
client_price(calculé par FixPay selon le BIN) est débité de votre wallet. Un solde insuffisant fait échouer l'émission avec le codeinsufficient_balance(HTTP 422). Source :InitiateB2bCardIssuance.php:87-97,bootstrap/app.php:134-138.
2.3 Notion de BIN#
Un BIN identifie le programme de carte utilisé pour l'émission.
- Vous référencez un BIN par son
bin_uuid(identifiant UUID public), pas par le numéro de BIN brut. Source :Bin.php:12-23,IssueB2bCardRequest.php:23-27. - Le
bin_uuidest optionnel dans la requête. S'il est absent, FixPay utilise le BIN par défaut configuré pour votre compte. Si ni l'un ni l'autre n'est disponible (ou si le BIN n'est pas activé côté FixPay), l'émission échoue en422bin_required. Source :B2bCardController.php:95-108, 44-46.
2.4 Statuts#
Statut de carte (status) — CardStatus.php:7-13 : pending, active,
frozen, cancelled.
État de l'ordre d'émission (state) — CardIssuanceState.php:7-12 : pending,
success, failed.
Devises — Currency.php:7-19 : XOF (échelle 0), USD (échelle 2).
3. Base URL & environnements#
Le domaine / hôte de base (ex. https://api.fixpay...) n'est pas présent dans le
code examiné. Il doit vous être communiqué par FixPay pour chaque environnement.
Ce qui est garanti par le code, c'est le chemin : les routes marchand sont
montées sous le préfixe API par défaut de Laravel (api) puis le groupe b2b, soit :
POST /api/b2b/cardsGET /api/b2b/cards/{uuid}POST /api/b2b/cards/{uuid}/reveal
Source : routes/api.php:52-55, bootstrap/app.php:42-50 (aucun apiPrefix
personnalisé → préfixe api par défaut).
Point critique pour la signature : le segment
/apifait partie de la valeur signée (requestUri). Voir §4.3.
Prérequis d'intégration : envoyez toujours l'en-tête Accept: application/json.
Sans cela, certaines erreurs rendues par le framework (validation, 404, 429) peuvent
ne pas être retournées en JSON. Ce point découle du comportement par défaut de Laravel
(négociation de contenu), pas d'une ligne explicite du projet.
4. Authentification HMAC (le cœur)#
Chaque requête aux 3 endpoints doit porter une signature HMAC-SHA256. Le middleware
VerifyB2bHmac vérifie, dans cet ordre (le premier échec l'emporte) :
- présence des 4 en-têtes ;
- clé publique connue,
active, non expirée ; - marchand
active; - signature valide ;
- timestamp dans la fenêtre ;
- nonce non déjà utilisé.
Source : VerifyB2bHmac.php:26-80.
4.1 Les 4 en-têtes d'authentification#
| En-tête | Rôle | Source |
|---|---|---|
X-FixPay-Key |
Votre public_key |
config/b2b.php:13 |
X-FixPay-Signature |
Signature HMAC-SHA256 (hex minuscule) | config/b2b.php:14 |
X-FixPay-Timestamp |
Timestamp Unix en secondes, entier | config/b2b.php:15 |
X-FixPay-Nonce |
Valeur unique par requête (par clé) | config/b2b.php:16 |
Chaque en-tête est lu puis trim. Si l'un des 4 est vide → 401 unauthorized.
Source : VerifyB2bHmac.php:30-37.
4.2 Construction de la chaîne canonique (pas-à-pas)#
La chaîne à signer est l'assemblage de 5 éléments, joints par un unique saut de
ligne \n (LF), sans saut de ligne final :
<MÉTHODE en MAJUSCULES>\n
<requestUri>\n
<timestamp>\n
<nonce>\n
<SHA-256 hex du corps brut>
Source unique : HmacVerifier::canonical() — HmacVerifier.php:11-25.
Règles exactes :
Méthode : passée en
strtoupper()→POSTouGET. (HmacVerifier.php:19)requestUri : voir §4.3. (
HmacVerifier.php:20,VerifyB2bHmac.php:56)timestamp : la valeur brute (trim) de l'en-tête
X-FixPay-Timestamp. (HmacVerifier.php:21)nonce : la valeur brute (trim) de l'en-tête
X-FixPay-Nonce. (HmacVerifier.php:22)hash du corps :
hash('sha256', corps_brut)→ digest SHA-256 en hexadécimal minuscule du corps brut de la requête (les octets exacts envoyés). Ce n'est pas le corps lui-même qui est inséré, mais son empreinte hex. (HmacVerifier.php:23,VerifyB2bHmac.php:59)Pour un corps vide (typiquement
GETetreveal), le digest est celui de la chaîne vide :e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.
4.3 Valeur EXACTE de requestUri#
requestUri = $request->getRequestUri(), c'est-à-dire le chemin exact envoyé sur
le fil, préfixe /api inclus, et query string incluse si présente, sans
normalisation ni décodage. Source : VerifyB2bHmac.php:56.
Concrètement, la valeur à signer est :
/api/b2b/cardspour l'émission ;/api/b2b/cards/<uuid>pour la consultation (le{uuid}tel qu'écrit dans l'URL) ;/api/b2b/cards/<uuid>/revealpour la révélation.
Si vous ajoutez une query string (ex. /api/b2b/cards/abc-123?foo=bar), elle fait
partie de la valeur signée.
Ambiguïté à respecter :
getRequestUri()renvoie l'URI telle que reçue (mêmes encodages de caractères, même casse de chemin). Signez exactement la forme réellement transmise dans la ligne de requête HTTP, sinon la comparaison échoue.
4.4 Calcul de la signature#
signature = hash_hmac('sha256', chaîne_canonique, secret) // hex minuscule
- Algorithme : HMAC-SHA256, sortie hexadécimale minuscule (pas de binaire).
- Clé : votre
secret(64 hex reçu à l'émission). - La signature complète est comparée en temps constant (
hash_equals) — elle n'est jamais tronquée.
Source : HmacVerifier.php:27-38.
4.5 Règles du timestamp#
- Format : timestamp Unix en secondes, entier (validé par
^-?\d+$). Pas de millisecondes, pas d'ISO-8601. - Fenêtre : la requête est acceptée si
abs(now - timestamp) <= 300secondes. Tolérance symétrique (±300 s), pilotable par FixPay viaB2B_REPLAY_WINDOW_SECONDS(défaut 300). - Hors fenêtre ou non entier →
401 unauthorized.
Source : HmacVerifier.php:40-55, config/b2b.php:6.
4.6 Règles du nonce#
- Valeur unique par requête, scopée à votre clé API (l'unicité porte sur le couple
(api_key_id, nonce)). - Un nonce déjà vu pour votre clé →
409 replay_detected(et non 401). - Le nonce n'est vérifié qu'après une signature et un timestamp valides.
- Durée de rétention (TTL) côté FixPay :
max(B2B_NONCE_TTL_SECONDS [défaut 900], 2 × fenêtre de rejeu)= 900 s avec les valeurs par défaut. Un même nonce ne peut donc pas être rejoué pendant au moins toute la fenêtre d'acceptation.
Recommandation : utilisez un UUID v4 par requête.
Source : VerifyB2bHmac.php:82-102, 130-133, config/b2b.php:8.
4.7 En-tête Idempotency-Key (POST /cards uniquement)#
En plus des 4 en-têtes HMAC, POST /api/b2b/cards exige l'en-tête
Idempotency-Key. Il ne fait PAS partie de la chaîne canonique HMAC — c'est un
en-tête applicatif distinct (voir §7).
- Obligatoire ; doit respecter la regex
^[A-Za-z0-9_.:-]{1,128}$. - Absent/vide →
422 idempotency_key_required. Invalide →422 idempotency_key_invalid.
Source : B2bCardController.php:32-40, 133-136.
4.8 Exemple complet — shell / curl + openssl#
#!/usr/bin/env bash
set -euo pipefail
SECRET="votre_secret_64_hex" # reçu une seule fois
PUBLIC_KEY="pk_votre_cle_publique"
BASE_URL="https://<hote-fourni-par-fixpay>" # non présent dans le code
METHOD="POST"
REQUEST_URI="/api/b2b/cards" # /api INCLUS — voir §4.3
BODY='{"cardholder_name":"Awa Diop"}' # corps brut EXACT qui sera envoyé
TIMESTAMP="$(date +%s)" # secondes Unix, entier
NONCE="$(uuidgen)" # unique par requête
# 1) SHA-256 hex du corps brut
BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')"
# 2) Chaîne canonique : 5 éléments joints par \n, SANS \n final
CANONICAL="$(printf '%s\n%s\n%s\n%s\n%s' "$METHOD" "$REQUEST_URI" "$TIMESTAMP" "$NONCE" "$BODY_HASH")"
# 3) Signature HMAC-SHA256 hex
SIGNATURE="$(printf '%s' "$CANONICAL" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')"
# 4) Requête — le corps envoyé (--data) DOIT être identique à $BODY
curl -sS -X "$METHOD" "$BASE_URL$REQUEST_URI" \
-H "X-FixPay-Key: $PUBLIC_KEY" \
-H "X-FixPay-Signature: $SIGNATURE" \
-H "X-FixPay-Timestamp: $TIMESTAMP" \
-H "X-FixPay-Nonce: $NONCE" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
--data "$BODY"
Le corps haché (
$BODY) et le corps envoyé (--data) doivent être strictement identiques, octet pour octet. N'utilisez pas d'outil qui reformate le JSON entre le hachage et l'envoi.
4.9 Exemple complet — Python#
import hashlib, hmac, time, uuid, json
import requests # pip install requests
SECRET = "votre_secret_64_hex" # reçu une seule fois
PUBLIC_KEY = "pk_votre_cle_publique"
BASE_URL = "https://<hote-fourni-par-fixpay>" # non présent dans le code
method = "POST"
request_uri = "/api/b2b/cards" # /api INCLUS — voir §4.3
# Corps brut EXACT en OCTETS : sérialisé une seule fois en bytes, puis réutilisé
# tel quel pour le hachage ET l'envoi — garantit octets signés == octets transmis
# quel que soit le contenu (json.dumps échappe le non-ASCII par défaut).
body = json.dumps({"cardholder_name": "Awa Diop"}, separators=(",", ":")).encode("utf-8")
timestamp = str(int(time.time())) # secondes Unix, entier
nonce = str(uuid.uuid4()) # unique par requête
# 1) SHA-256 hex du corps brut (les octets exacts qui seront envoyés)
body_hash = hashlib.sha256(body).hexdigest()
# 2) Chaîne canonique : 5 éléments joints par "\n", sans "\n" final
canonical = "\n".join([method.upper(), request_uri, timestamp, nonce, body_hash])
# 3) Signature HMAC-SHA256 hex
signature = hmac.new(
SECRET.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256
).hexdigest()
headers = {
"X-FixPay-Key": PUBLIC_KEY,
"X-FixPay-Signature": signature,
"X-FixPay-Timestamp": timestamp,
"X-FixPay-Nonce": nonce,
"Idempotency-Key": str(uuid.uuid4()),
"Content-Type": "application/json",
"Accept": "application/json",
}
# 4) IMPORTANT : envoyer la MÊME chaîne brute (data=body), pas json=...
resp = requests.post(BASE_URL + request_uri, data=body, headers=headers)
print(resp.status_code, resp.json())
Pour un
GETou unrevealsans corps,body = b"": le hash du corps deviente3b0c442...b855et vous n'envoyez pas de--data/data.
5. Référence des endpoints#
Rappel : les 4 en-têtes HMAC (§4.1) sont requis sur les trois endpoints. Seul
POST /cards exige en plus Idempotency-Key.
5.1 POST /api/b2b/cards — Émission d'une carte#
En-têtes : 4 en-têtes HMAC + Idempotency-Key (obligatoire).
Corps de requête (IssueB2bCardRequest.php:20-31) :
| Champ | Requis | Contraintes |
|---|---|---|
bin_uuid |
optionnel (nullable) |
uuid ; doit exister dans les BIN où fixpay_enabled = true |
cardholder_name |
requis | string, max:255 |
cardholder_email |
optionnel (nullable) |
email, max:255 |
Exemple de requête :
POST /api/b2b/cards HTTP/1.1
Host: <hote-fourni-par-fixpay>
X-FixPay-Key: pk_...
X-FixPay-Signature: <hex>
X-FixPay-Timestamp: 1755253350
X-FixPay-Nonce: 7c3d...-uuid
Idempotency-Key: order-2026-08-15-0001
Content-Type: application/json
Accept: application/json
{"cardholder_name":"Awa Diop","cardholder_email":"awa@example.com"}
Réponse de succès : HTTP 201, message = "b2b_card_issuance_initiated",
data = B2bCardIssuanceOrderResource. Source : B2bCardController.php:57-61,
B2bCardIssuanceOrderResource.php:22-36.
{
"message": "b2b_card_issuance_initiated",
"data": {
"uuid": "b1f2c3d4-5678-4abc-9def-0123456789ab",
"state": "pending",
"client_price": { "amount_minor": 6500, "currency": "XOF", "scale": 0 },
"card": null,
"cardholder_name": "Awa Diop",
"cardholder_email": "awa@example.com",
"failure_reason": null,
"created_at": "2026-08-15T10:22:30+00:00"
},
"errors": null
}
Points essentiels à comprendre (comportements réels du code) :
- HTTP 201 est toujours renvoyé, même si l'ordre finit en
state = "failed". Vérifiez doncdata.stateetdata.failure_reason. Source :B2bCardController.php:57-61. data.cardvautnullà l'émission : la carte est créée/activée de façon asynchrone. L'uuidde la carte n'est pas disponible dans cette réponse. Le suivi de la création (jusqu'à obtenir la carte) se fait via webhook (§9) ou par re-consultation ultérieure. Source :B2bCardIssuanceOrderResource.php:30,InitiateB2bCardIssuance.php(persist sanscard_id).client_price.currencyest codé en durXOF: c'est le montant débité de votre wallet, indépendamment de la deviseUSDde la carte. Source :B2bCardIssuanceOrderResource.php:29.
Champs de data (émission) — B2bCardIssuanceOrderResource.php:22-36 :
| Champ | Type | Notes |
|---|---|---|
uuid |
string | UUID de l'ordre d'émission |
state |
string | pending | success | failed |
client_price |
objet | { amount_minor:int, currency:"XOF", scale:0 } |
card |
objet | null |
B2bCardResource si la carte existe, sinon null |
cardholder_name |
string | |
cardholder_email |
string | null |
|
failure_reason |
string | null |
renseigné si state = failed |
created_at |
string | null |
ISO-8601 |
Erreurs spécifiques :
| Cas | HTTP | code |
|---|---|---|
Idempotency-Key manquant/vide |
422 | idempotency_key_required |
Idempotency-Key invalide (regex) |
422 | idempotency_key_invalid |
| BIN non résolu | 422 | bin_required |
bin_uuid pointant un BIN désactivé |
422 | rejet validation Laravel (§6.4), clé bin_uuid — pas bin_not_issuable |
| BIN désactivé entre validation et émission (course) | 422 | bin_not_issuable (garde-fou défensif, rare) |
| Solde wallet insuffisant | 422 | insufficient_balance |
Même Idempotency-Key, corps différent |
409 | idempotency_key_conflict |
Validation de champ (cardholder_name, etc.) |
422 | format Laravel par défaut (voir §6) |
Sources : B2bCardController.php:34-46, BinNotIssuableException.php:9-17,
bootstrap/app.php:110-114, 128-132, 134-138, IdempotencyConflictException.php.
5.2 GET /api/b2b/cards/{uuid} — Consultation d'une carte#
En-têtes : 4 en-têtes HMAC uniquement. Aucun corps.
Réponse de succès : HTTP 200, message = null, data = B2bCardResource.
Source : B2bCardController.php:64-68.
{
"message": null,
"data": {
"uuid": "c9a8b7d6-1234-4e5f-8a9b-abcdef012345",
"status": "active",
"pan_last4": "4242",
"expiry_month": 12,
"expiry_year": 2028,
"cardholder_name": "Awa Diop",
"cardholder_email": "awa@example.com",
"currency": "USD",
"created_at": "2026-08-15T10:22:31+00:00"
},
"errors": null
}
Champs de data — B2bCardResource.php:19-32 :
| Champ | Type | Notes |
|---|---|---|
uuid |
string | UUID de la carte |
status |
string | pending | active | frozen | cancelled |
pan_last4 |
string | null |
4 derniers chiffres |
expiry_month |
int | null |
valeur brute du modèle |
expiry_year |
int | null |
valeur brute du modèle |
cardholder_name |
string | null |
|
cardholder_email |
string | null |
|
currency |
string | USD (ou XOF) |
created_at |
string | null |
ISO-8601 |
Réserve : les types PHP exacts de
pan_last4,expiry_month,expiry_yearne sont pas re-vérifiés au niveau des casts du modèleCard(hors périmètre) ; la ressource expose les attributs bruts tels quels.
Erreurs spécifiques :
| Cas | HTTP | Notes |
|---|---|---|
| Carte inexistante | 404 | format Laravel par défaut (§6) |
| Carte appartenant à un autre marchand | 404 | abort(404) — même forme |
Source : B2bCardController.php:110-120.
5.3 POST /api/b2b/cards/{uuid}/reveal — Révélation PAN/CVV#
En-têtes : 4 en-têtes HMAC uniquement (pas d'Idempotency-Key). Aucun corps.
Prérequis : votre compte doit avoir can_reveal_pan = true (activé par FixPay via
un workflow d'approbation). Sinon → 403 pan_reveal_not_allowed. Source :
B2bCardController.php:76-78, PanRevealNotAllowedException.php:9-17.
Réponse de succès : HTTP 200, message = "b2b_card_revealed", avec les
en-têtes de réponse Cache-Control: no-store, max-age=0 et Pragma: no-cache.
Source : B2bCardController.php:82-92.
{
"message": "b2b_card_revealed",
"data": {
"pan": "4242424242424242",
"cvv": "123",
"expiry": "12/28",
"cardholder_name": "Awa Diop"
},
"errors": null
}
Champs de data — B2bCardController.php:83-88, CardRevealResult.php:9-18 :
| Champ | Type | Notes |
|---|---|---|
pan |
string | numéro de carte complet |
cvv |
string | |
expiry |
string | null |
format "MM/YY" quand expiry_month et expiry_year existent (ex. mois 9 / 2027 → "09/27") |
cardholder_name |
string | null |
Ambiguïté
expiry: siexpiry_month/expiry_yearsont absents, la valeur retombe surdetails->expiryDatefourni par le fournisseur VCC, dont le format n'est pas vérifié dans le code lu. La valeur peut êtrenull. Source :RevealCard.php:49-57.
Erreurs spécifiques :
| Cas | HTTP | code |
|---|---|---|
| Révélation PAN désactivée pour le marchand | 403 | pan_reveal_not_allowed |
| Carte annulée / sans identifiant VCC / détails fournisseur absents | 422 | card_not_actionable |
| Carte inexistante / non possédée | 404 | format Laravel par défaut (§6) |
Sources : PanRevealNotAllowedException.php:9-17, CardNotActionableException.php:9-17,
RevealCard.php:25-32, B2bCardController.php:110-120.
5.4 Cycle de vie d'une carte (freeze / unfreeze / cancel)#
Trois endpoints pilotent le cycle de vie d'une carte déjà émise :
| Méthode + chemin | Effet | message succès |
|---|---|---|
POST /api/b2b/cards/{uuid}/freeze |
Gèle la carte (statut → frozen) |
b2b_card_frozen |
POST /api/b2b/cards/{uuid}/unfreeze |
Réactive la carte (statut → active) |
b2b_card_unfrozen |
POST /api/b2b/cards/{uuid}/cancel |
Annule définitivement la carte (statut → cancelled) |
b2b_card_cancelled |
En-têtes : les 4 en-têtes HMAC uniquement (§4.1). Pas d'Idempotency-Key
(le cycle de vie est déjà idempotent, voir plus bas). Aucun corps de requête (le
hash du corps vide e3b0c442...b855 entre dans la signature, comme pour reveal).
Réponse de succès : HTTP 200, data = B2bCardResource de la carte mise à
jour (même forme qu'au §5.2), message selon le tableau ci-dessus. Source :
B2bCardController.php:101-120.
{
"message": "b2b_card_frozen",
"data": {
"uuid": "c9a8b7d6-1234-4e5f-8a9b-abcdef012345",
"status": "frozen",
"pan_last4": "4242",
"expiry_month": 12,
"expiry_year": 2028,
"cardholder_name": "Awa Diop",
"cardholder_email": "awa@example.com",
"currency": "USD",
"created_at": "2026-08-15T10:22:31+00:00"
},
"errors": null
}
Idempotence. Chaque opération est idempotente : rejouer une transition vers un
statut déjà atteint ne provoque aucune erreur et renvoie 200 avec la carte
inchangée (no-op). Ainsi freeze sur une carte déjà frozen, unfreeze sur une carte
déjà active, ou cancel sur une carte déjà cancelled renvoient 200.
Pas de suppression dure. Il n'existe aucune suppression physique d'une carte :
annuler = passage au statut cancelled (principe d'immuabilité). La ressource reste
consultable via GET /cards/{uuid} avec status = cancelled. Une carte cancelled est
un état terminal : elle ne peut plus être ni gelée, ni réactivée.
Erreurs spécifiques :
| Cas | HTTP | code |
|---|---|---|
freeze d'une carte non active (ou sans identifiant fournisseur VCC) |
422 | card_not_actionable |
unfreeze d'une carte non frozen (ou sans identifiant fournisseur VCC) |
422 | card_not_actionable |
cancel d'une carte sans identifiant fournisseur VCC |
422 | card_not_actionable |
cancel d'une carte au solde > 0 |
422 | card_has_balance |
| Carte inexistante / non possédée | 404 | format Laravel par défaut (§6) |
Pour annuler une carte au solde positif, videz-la d'abord (le solde carte doit être
nul), puis rappelez cancel. Sources : CardNotActionableException.php:9-17,
CardHasBalanceException.php:9-17, SuspendCard.php, EnableCard.php, CancelCard.php,
B2bCardController.php:101-120.
6. Enveloppe de réponse & catalogue d'erreurs#
6.1 Enveloppe standard (ApiResponse)#
Toutes les réponses de succès et les erreurs applicatives mappées ont
exactement trois clés de premier niveau : message, data, errors. Il n'y a
pas de clé meta, ni de clé code au premier niveau. Source :
ApiResponse.php:14-37.
Succès — errors toujours null :
{ "message": <string|null>, "data": <object|null>, "errors": null }
Erreur applicative — data toujours null, errors contient { "code": "..." } :
{ "message": "<code>", "data": null, "errors": { "code": "<code>" } }
Note : en erreur applicative, le champ
messageest le code machine (ex."unauthorized"), identique àerrors.code. Ce n'est pas un message humain.
6.2 Deux familles d'erreurs (divergence réelle du code)#
- Famille A — via
ApiResponse: respecte l'enveloppe{message, data, errors}avecerrors.code. Couvre l'auth HMAC et les erreurs métier des endpoints. - Famille B — rendue par Laravel par défaut : n'utilise pas l'enveloppe.
Couvre la validation 422, le 404, le 429. Forme JSON différente,
définie par le framework, pas par le code projet — ne présumez pas d'un
errors.codepour ces cas.
Pour garantir un rendu JSON de la Famille B, envoyez Accept: application/json.
6.3 Catalogue — Famille A (enveloppe ApiResponse)#
code |
HTTP | Cause | Source |
|---|---|---|---|
unauthorized |
401 | En-tête d'auth manquant ; clé inconnue / non active / expirée ; marchand inactif ; signature invalide ; timestamp hors fenêtre |
VerifyB2bHmac.php:125-128 |
replay_detected |
409 | Nonce déjà utilisé pour cette clé | VerifyB2bHmac.php:130-133 |
idempotency_key_required |
422 | POST /cards sans Idempotency-Key |
B2bCardController.php:34-36 |
idempotency_key_invalid |
422 | Idempotency-Key hors regex |
B2bCardController.php:38-40 |
idempotency_key_conflict |
409 | Même clé réutilisée avec un corps/chemin/méthode différent | IdempotencyConflictException, bootstrap/app.php:128-132 |
bin_required |
422 | Aucun BIN résolu (ni bin_uuid, ni défaut valide) |
B2bCardController.php:44-46 |
bin_not_issuable |
422 | BIN résolu mais non fixpay_enabled |
BinNotIssuableException.php:9-17, bootstrap/app.php:110-114 |
insufficient_balance |
422 | Wallet marchand insuffisant | bootstrap/app.php:134-138 |
pan_reveal_not_allowed |
403 | reveal alors que can_reveal_pan = false |
PanRevealNotAllowedException.php:9-17, bootstrap/app.php:116-120 |
card_not_actionable |
422 | Carte annulée / sans identifiant VCC / détails absents ; transition de cycle de vie illégale (freeze d'une carte non active, unfreeze d'une carte non frozen, cancel sans identifiant VCC) |
CardNotActionableException.php:9-17 |
card_has_balance |
422 | cancel d'une carte dont le solde est > 0 (videz la carte d'abord) |
CardHasBalanceException.php:9-17 |
Le middleware HMAC fusionne toutes les causes d'échec d'authentification en un seul
unauthorized/ 401 (en-tête manquant, clé inconnue, signature invalide, timestamp expiré…). La réponse ne distingue jamais la cause précise. C'est intentionnel dans le code. Seul le rejeu de nonce sort en409 replay_detected.
Réserve : lors de l'émission, l'appel synchrone au fournisseur VCC peut lever d'autres sous-classes de
CardExceptionquebin_not_issuable. Elles surfaceraient avec la même forme (message = code,errors.code, statut propre), mais elles ne sont pas énumérées dans le code lu. À ne pas présumer.
Exemples :
{ "message": "unauthorized", "data": null, "errors": { "code": "unauthorized" } }
{ "message": "replay_detected", "data": null, "errors": { "code": "replay_detected" } }
{ "message": "insufficient_balance", "data": null, "errors": { "code": "insufficient_balance" } }
6.4 Famille B — erreurs Laravel par défaut#
Ces réponses n'ont pas l'enveloppe ApiResponse. Leur forme provient du framework
et n'est pas définie dans le code projet (les chaînes de message ci-dessous sont
illustratives, générées par Laravel).
Validation 422 (échec de IssueB2bCardRequest) — forme {message, errors},
sans data, sans code :
{
"message": "The cardholder name field is required.",
"errors": {
"cardholder_name": ["The cardholder name field is required."],
"bin_uuid": ["The selected bin uuid is invalid."]
}
}
404 not found (carte inexistante ou non possédée) — forme {message} seule :
{ "message": "..." }
429 rate limit (dépassement du throttle) — forme {message} (typiquement
"Too Many Attempts.") + en-têtes Retry-After / X-RateLimit-* fournis par le
framework :
{ "message": "Too Many Attempts." }
Source : bootstrap/app.php:73-145 (aucun render() custom pour ces cas),
IssueB2bCardRequest.php:20-31, routes/api.php:52.
7. Idempotence (POST /cards)#
- En-tête
Idempotency-Keyobligatoire sur l'émission uniquement. - Format :
^[A-Za-z0-9_.:-]{1,128}$(lettres, chiffres,_,.,:,-, de 1 à 128 caractères). Source :B2bCardController.php:133-136. - L'idempotence est scopée par marchand (espace
b2b_issue:<merchant_id>). Source :InitiateB2bCardIssuance.php:60-66. - Réutilisation à l'identique (même clé, même corps) : la requête est rejouée et renvoie le même ordre — vous pouvez re-tenter sans risque de double émission.
- Conflit : même clé mais corps / chemin / méthode différents →
409 idempotency_key_conflict. L'empreinte comparée estsha256(méthode + "\n" + path + "\n" + sha256(corps brut)). Source :B2bCardController.php:138-145(empreinte),Idempotency.php:55-64(comparaison).
Recommandation : générez une clé stable par intention d'émission (ex. votre identifiant de commande interne), et rejouez-la telle quelle en cas de time-out réseau.
8. Limites de débit#
- Le groupe B2B applique
throttle:b2b. Source :routes/api.php:52. - Quota par défaut : 120 requêtes / minute, pilotable côté FixPay via
B2B_THROTTLE_PER_MINUTE. Source :AppServiceProvider.php:221-229,config/b2b.php:10. - Clé de comptage : par clé publique présentée (
b2b-key:<publicKey>) si l'en-têteX-FixPay-Keyest renseigné ; sinon par IP (b2b-ip:<ip>). Source :AppServiceProvider.php:223-228. - Nuance : le comptage utilise la valeur brute de l'en-tête clé, sans vérifier sa validité — le quota est décompté même pour une clé ensuite rejetée en 401.
- Dépassement → HTTP 429 (Famille B, §6.4), avec en-têtes
Retry-After/X-RateLimit-*.
9. Webhooks (implémenté et actif)#
FixPay pousse des webhooks serveur-à-serveur vers votre URL lorsque le cycle
d'émission d'une carte se termine. Ce mécanisme est implémenté et actif (outbox,
livraison planifiée, signature HMAC, retries, dead-letter). Source :
OutboxWriter.php, DeliverB2bWebhooks.php, migrations b2b_webhook_deliveries /
outbox.
9.1 Enregistrement de l'URL#
Vous n'enregistrez pas votre URL vous-même : l'admin FixPay fixe le
webhook_url (HTTPS public obligatoire) à la création de votre compte. Source :
CreateMerchant.php:31, CreateMerchantRequest.php:27, PublicHttpsUrl.php.
Limite du code actuel : il n'existe aucun endpoint de mise à jour du
webhook_urlaprès création (ni self-service, ni admin). Pour le changer, adressez- vous à FixPay. Source :routes/api.php:100-110(pas de méthodeupdate).
9.2 Types d'événements (liste exhaustive : 2)#
Enum OutboxEventType.php:9-10 — seuls ces deux types existent :
card.issued— carte émise avec succès.card.issue_failed— échec de l'émission.
Aucun autre événement de cycle de vie n'est émis (pas de card.activated,
card.frozen, card.reveal, etc.). Aucun événement de test/ping n'est implémenté.
GET /cards et reveal n'émettent aucun webhook.
9.3 Forme du payload#
Corps encodé avec JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE. Source :
DeliverB2bWebhooks.php:274-282.
{
"event_id": "<uuid de l'événement>",
"event_type": "card.issued",
"occurred_at": "<ISO-8601 | null>",
"data": { ... }
}
data pour card.issued (ConfirmCardIssuance.php:100-107) :
order_uuid, card_uuid, pan_last4, status, currency, cardholder_name.
data pour card.issue_failed (CompensateCardIssuance.php:64-67) :
order_uuid, failure_reason.
9.4 En-têtes de livraison#
Source : DeliverB2bWebhooks.php:287-296, config/b2b.php:24-27.
| En-tête | Valeur |
|---|---|
X-FixPay-Signature |
sha256=<hmac_hex> (voir §9.5) |
X-FixPay-Timestamp |
timestamp Unix (secondes, string) — informatif, non signé |
X-FixPay-Event-Id |
UUID de l'événement (= event_id du corps) |
X-FixPay-Event-Type |
card.issued | card.issue_failed |
Idempotency-Key |
même UUID que X-FixPay-Event-Id (déduplication) |
Content-Type |
application/json |
9.5 Vérification de la signature (webhook entrant)#
Attention — sémantique DIFFÉRENTE de l'auth des requêtes sortantes du §4. Le webhook signe le corps brut SEUL (pas de méthode/URI/timestamp/nonce), utilise un secret dédié (
webhook_secret), et le préfixesha256=fait partie de la valeur d'en-tête. Le nom d'en-têteX-FixPay-Signatureest identique, mais la formule ne l'est pas.
Formule exacte (DeliverB2bWebhooks.php:121-123, HmacVerifier.php:27-30) :
X-FixPay-Signature = "sha256=" + hex( HMAC_SHA256( corps_brut, webhook_secret ) )
Algorithme de vérification côté marchand :
- Lire le corps HTTP brut reçu (ne pas re-sérialiser).
- Calculer
expected = "sha256=" + hex(HMAC_SHA256(rawBody, webhook_secret)). - Comparer à l'en-tête
X-FixPay-Signatureen temps constant. - (Optionnel) dédupliquer via
X-FixPay-Event-Id/Idempotency-Key.
Le
X-FixPay-Timestampdu webhook n'entre pas dans la signature ; il ne peut donc pas servir de preuve cryptographique anti-rejeu. UtilisezX-FixPay-Event-Idpour la déduplication.
Exemple (Node.js) :
const crypto = require("crypto");
function verify(rawBody, headerSignature, webhookSecret) {
const expected =
"sha256=" +
crypto.createHmac("sha256", webhookSecret).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(headerSignature || "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
9.6 Livraison, retries, backoff#
Source : DeliverB2bWebhooks.php, config/b2b.php:19-28, config/scheduler.php:50.
- Livraison
POSTsur votrewebhook_url,Content-Type: application/json, timeout par défaut 10 s, sans suivre les redirections, résolution DNS épinglée (anti-SSRF). - Non synchrone : la livraison dépend d'un scheduler cadencé à ~60 s (latence nominale jusqu'à ~1 minute après l'événement).
- Succès = réponse HTTP 2xx.
- Tentatives :
max_attemptspar défaut 6. - Backoff (secondes) :
[60, 300, 900, 3600, 10800]; au-delà, la dernière valeur (3 h) est réutilisée. - Échec définitif après
max_attempts→ dead-letter (alerte interne FixPay). - Chaque tentative est tracée côté FixPay (statut HTTP, extrait de réponse ≤ 500 caractères, erreur, prochaine tentative).
Recommandations d'intégration : répondez rapidement en 2xx ; traitez de façon
idempotente via X-FixPay-Event-Id ; attendez-vous à des re-livraisons du même
événement.