FixPay · Documentation APIRetour au site

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 201 confirme 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éral pk_ suivi de 32 caractères hexadécimaux minuscules (ex. pk_ab12...). Elle est transmise en clair dans l'en-tête X-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 secret et le webhook_secret ne sont affichés qu'une seule fois, au moment de l'émission de la clé (réponse admin, en-têtes Cache-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 code insufficient_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_uuid est 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 en 422 bin_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/cards
  • GET /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 /api fait 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) :

  1. présence des 4 en-têtes ;
  2. clé publique connue, active, non expirée ;
  3. marchand active ;
  4. signature valide ;
  5. timestamp dans la fenêtre ;
  6. 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 :

  1. Méthode : passée en strtoupper() → POST ou GET. (HmacVerifier.php:19)

  2. requestUri : voir §4.3. (HmacVerifier.php:20, VerifyB2bHmac.php:56)

  3. timestamp : la valeur brute (trim) de l'en-tête X-FixPay-Timestamp. (HmacVerifier.php:21)

  4. nonce : la valeur brute (trim) de l'en-tête X-FixPay-Nonce. (HmacVerifier.php:22)

  5. 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 GET et reveal), 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/cards pour l'émission ;
  • /api/b2b/cards/<uuid> pour la consultation (le {uuid} tel qu'écrit dans l'URL) ;
  • /api/b2b/cards/<uuid>/reveal pour 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) <= 300 secondes. Tolérance symétrique (±300 s), pilotable par FixPay via B2B_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 GET ou un reveal sans corps, body = b"" : le hash du corps devient e3b0c442...b855 et 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 donc data.state et data.failure_reason. Source : B2bCardController.php:57-61.
  • data.card vaut null à l'émission : la carte est créée/activée de façon asynchrone. L'uuid de 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 sans card_id).
  • client_price.currency est codé en dur XOF : c'est le montant débité de votre wallet, indépendamment de la devise USD de 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_year ne sont pas re-vérifiés au niveau des casts du modèle Card (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 : si expiry_month/expiry_year sont absents, la valeur retombe sur details->expiryDate fourni par le fournisseur VCC, dont le format n'est pas vérifié dans le code lu. La valeur peut être null. 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 message est 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} avec errors.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.code pour 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 en 409 replay_detected.

Réserve : lors de l'émission, l'appel synchrone au fournisseur VCC peut lever d'autres sous-classes de CardException que bin_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-Key obligatoire 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 est sha256(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ête X-FixPay-Key est 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_url après création (ni self-service, ni admin). Pour le changer, adressez- vous à FixPay. Source : routes/api.php:100-110 (pas de méthode update).

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éfixe sha256= fait partie de la valeur d'en-tête. Le nom d'en-tête X-FixPay-Signature est 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 :

  1. Lire le corps HTTP brut reçu (ne pas re-sérialiser).
  2. Calculer expected = "sha256=" + hex(HMAC_SHA256(rawBody, webhook_secret)).
  3. Comparer à l'en-tête X-FixPay-Signature en temps constant.
  4. (Optionnel) dédupliquer via X-FixPay-Event-Id / Idempotency-Key.

Le X-FixPay-Timestamp du webhook n'entre pas dans la signature ; il ne peut donc pas servir de preuve cryptographique anti-rejeu. Utilisez X-FixPay-Event-Id pour 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 POST sur votre webhook_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_attempts par 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.