Base : https://api.lahsab.com. Tous les montants sont des entiers en dinars (DZD, sans centimes). L’intégration serveur-à-serveur s’authentifie par x-api-key, et l’environnement est déduit du préfixe de la clé (lsk_sandbox_ / lsk_live_). Chaque clé porte des scopes : elle n’atteint que les endpoints qu’on lui a explicitement ouverts. Plusieurs endpoints sont publics (les pages hébergées et le dépôt de preuve) et ne demandent aucune authentification.
L’API couvre deux surfaces, qui se complètent :
- Paiements : un
PaymentIntentpour encaisser une somme, une fois. La primitive de base. - Facturation : clients, produits, prix, abonnements et factures, quand l’encaissement se répète. Voir Abonnements.
Clés API
Une clé est un porteur : quiconque la détient agit en votre nom. lahsab la borne sur trois axes, et vous donne de quoi la surveiller.
Scopes
Une clé n’accède qu’aux endpoints couverts par ses scopes. Un scope refusé répond 403 API_KEY_SCOPE_MISSING, en nommant ce qui manque.
| Scope | Ce qu’il ouvre |
|---|---|
payments:read | Lire les intents et le résumé d’encaissement. |
payments:write | Créer des intents, purger la sandbox, appeler les endpoints de simulation. |
payments:confirm | Trancher le sort d’un paiement : confirmer, rejeter, valider une preuve. |
proofs:read | Télécharger le justificatif déposé par l’acheteur. |
billing:read | Lire clients, produits, prix, coupons, factures, abonnements. |
billing:write | Créer et modifier le catalogue, les abonnements, les factures. |
webhooks:read | Lire le journal des livraisons, écouter le flux sandbox (lahsab listen). |
webhooks:write | Renvoyer une livraison, émettre un événement test. |
Un scope write couvre le read de sa famille : billing:write suffit pour lire une facture. Les familles ne se recouvrent jamais entre elles.
Une clé ne s’affiche qu’une fois
Elle est affichée intégralement à sa création, et jamais plus. lahsab n’en conserve qu’une empreinte SHA-256 : la console vous montre ensuite son préfixe (lsk_live_a1b2c3d4…9f3c), de quoi l’identifier, pas de quoi la rejouer. Perdue, une clé se remplace, elle ne se relit pas.
Vous pouvez tenir jusqu’à 10 clés actives par environnement, une par service, ce qui rend une révocation ciblée possible.
Rotation avec période de grâce
La rotation émet une nouvelle clé avec les mêmes scopes, et laisse l’ancienne valide le temps que vous choisissez : de zéro (coupure immédiate, pour une fuite) à 7 jours (pour un déploiement tranquille). Passé le délai, l’ancienne clé répond 401 API_KEY_EXPIRED ; révoquée, 401 API_KEY_REVOKED.
Faites tourner vos clés depuis la console Développeurs. Une rotation avec grâce ne coupe personne : déployez la nouvelle clé, laissez expirer l’ancienne.
Compléter les droits d’une clé en service
Les droits d’une clé se modifient depuis la console Développeurs, sans en changer le secret : la modification s’applique dès l’appel suivant, sans redéploiement. C’est la sortie prévue quand une clé en service répond 403 API_KEY_SCOPE_MISSING sur un geste dont vous avez besoin.
Journal des appels
Chaque appel authentifié par une clé est enregistré : horodatage, clé utilisée, route, code de réponse, adresse IP, durée. Les appels refusés y figurent aussi, c’est même leur intérêt principal. Le journal se consulte dans la console Développeurs et se conserve 30 jours.
C’est ce qui rend une fuite détectable : une clé qui sert depuis une IP que vous ne reconnaissez pas, ou qui appelle une route que ce service n’appelle jamais, se voit.
Limites de débit
Le débit se compte par clé, pas par adresse IP : votre serveur de production et votre script de réconciliation ne se gênent pas mutuellement, même derrière la même sortie réseau.
| Trafic | Limite |
|---|---|
| Appels portant une clé API | 1200 par minute et par clé |
| Trafic sans clé (pages hébergées, dépôt de preuve) | 300 par minute et par IP, plus les limites propres à ces routes |
Au-dessus, 429. Les en-têtes X-RateLimit-* accompagnent chaque réponse.
Idempotence
Toutes les routes POST acceptent un en-tête Idempotency-Key. Rejouez la même clé avec le même corps, et vous récupérez la même réponse, sans créer un second objet. C’est la protection contre les doubles clics, les timeouts et les réessais.
-H "Idempotency-Key: sub-user_abc123-pro-mensuel"
Choisissez une clé dérivée de votre métier (sub-{userId}-{plan}, inv-{orderRef}), pas un UUID aléatoire : un UUID régénéré à chaque tentative ne protège de rien.
| Cas | Réponse |
|---|---|
| Même clé, même corps | La réponse d’origine, rejouée. |
| Même clé, corps différent | 409 IDEMPOTENCY_KEY_REUSED. |
| Même clé, requête d’origine encore en cours | 409 IDEMPOTENCY_KEY_IN_FLIGHT. Réessayez. |
Versions des objets
Chaque intent, facture, abonnement, client, produit, prix et coupon porte un entier version, incrémenté à chaque écriture, et un updatedAt. Les deux champs sont présents à l’identique sur la réponse de l’API et dans le payload webhook.
version est ce qui vous permet d’ignorer un payload périmé, qu’il vienne d’un rejeu d’idempotence ou d’une livraison webhook réessayée. updatedAt sert à l’affichage et au débogage, jamais à ordonner. Voir Ordonner votre miroir.
Langue du checkout
Les pages servies à l’acheteur (paiement, facture hébergée, portail) sont rendues en français, en arabe (RTL compris) ou en anglais. La langue n’est pas devinée depuis le navigateur : c’est vous qui la décidez, parce que vous savez déjà dans quelle langue votre utilisateur lit votre application.
Réglez d’abord vos langues activées et votre langue par défaut dans le dashboard, sous Réglages. Par défaut, le français et l’arabe sont activés et le français est la langue par défaut. L’anglais s’active au même endroit, il n’est jamais activé pour vous.
Ensuite, POST /payments/intents, POST /invoices, POST /subscriptions et POST /customers acceptent un champ optionnel locale valant fr, ar ou en. La langue effective est résolue dans cet ordre :
- le
localepassé à la création de l’objet, - le
localedu client rattaché, - votre langue par défaut,
fr.
-d '{ "amount": 2500, "locale": "ar" }'
Une langue que vous n’avez pas activée est refusée par 400 MERCHANT_LOCALE_NOT_AVAILABLE, sans repli silencieux sur votre défaut. Activez la langue avant de l’envoyer depuis votre application.
La langue est figée à la création, puis renvoyée dans locale sur l’objet et dans les webhooks. Changer votre défaut plus tard n’altère donc pas une facture déjà ouverte : la page qu’un acheteur a sous les yeux ne change pas de langue en cours de route. Ce sont les objets suivants qui suivent le nouveau réglage.
Pour les renouvellements d’abonnement, aucune requête n’a lieu au moment de l’émission : c’est notre horloge de facturation qui crée la facture. La langue doit donc être persistée quelque part.
| Ce que vous faites | Ce qui arrive aux renouvellements |
|---|---|
locale sur le client (à la création ou par PATCH /customers/:id) | Chaque facture émise le relit. Un changement prend effet dès la suivante. |
locale sur POST /subscriptions | L’abonnement est épinglé : toutes ses factures gardent cette langue, quoi que devienne le client. |
| Ni l’un ni l’autre | Votre langue par défaut s’applique. |
Les trois pages publiques renvoient en plus locales, la liste de vos langues activées. C’est ce qui alimente le sélecteur de langue affiché à l’acheteur, masqué quand vous n’en activez qu’une.
Retour vers votre site
Les pages servies à l’acheteur sont des culs-de-sac : une fois la preuve déposée, il n’a aucune sortie vers votre site ou votre application. Un champ returnUrl ajoute sur ces pages un bouton « Retourner sur votre marque ».
Deux garde-fous à connaître avant de câbler quoi que ce soit.
Second garde-fou : jamais de redirection automatique. Le bouton attend un clic. La page porte encore de l’information utile à ce moment (la preuve est-elle passée, sous quel délai attendre la confirmation, à qui écrire), on n’en éjecte pas l’acheteur.
Déclarer vos destinations
Dans le dashboard, sous Réglages, deux réglages :
| Réglage | Rôle |
|---|---|
| Adresse de retour par défaut | Utilisée quand votre appel API ne transmet rien. Laissez vide pour n’afficher aucun bouton. |
| Domaines autorisés | La liste blanche, 10 entrées au maximum. Une returnUrl transmise par API dont le domaine n’y figure pas est refusée par 400 MERCHANT_RETURN_URL_NOT_ALLOWED. |
Le domaine de votre adresse par défaut est autorisé d’office, vous n’avez pas à le redéclarer.
Sont acceptés : les URL https, les schémas d’application (votreapp://, à déclarer explicitement), et http uniquement sur loopback (http://localhost:3000) pour votre boucle de développement. Une adresse d’une autre forme est refusée à l’enregistrement de vos réglages par 400 MERCHANT_RETURN_URL_INVALID. Transmise sur un appel de création, elle tombe sous le même 400 MERCHANT_RETURN_URL_NOT_ALLOWED que n’importe quel domaine hors liste. Une adresse ne dépasse pas 2048 caractères.
Cette liste blanche existe parce qu’un compte lahsab s’ouvre sans validation préalable. Sans elle, n’importe qui transformerait une page lahsab.com en redirecteur ouvert vers un site d’hameçonnage. C’est le même mécanisme que les redirect URIs d’OAuth.
Transmettre une destination
POST /payments/intents, POST /invoices, POST /subscriptions et POST /portal-sessions acceptent un champ optionnel returnUrl.
-d '{ "amount": 2500, "returnUrl": "https://app.votre-site.dz/commandes/42" }'
L’adresse servie à l’acheteur est résolue dans cet ordre :
- la
returnUrlde l’objet, - celle de la facture dont l’intent découle, sinon celle de l’abonnement dont la facture découle,
- votre adresse de retour par défaut,
- rien, et aucun bouton n’apparaît.
Contrairement à locale, l’adresse par défaut n’est pas figée à la création : la changer dans vos réglages corrige aussi les liens déjà émis. Une returnUrl transmise par API, elle, est stockée sur l’objet, et cesse d’être servie si vous retirez son domaine de la liste blanche.
Ce que vous recevez au retour
L’adresse est complétée de paramètres au moment du rendu, sans écraser votre query string :
| Paramètre | Contenu |
|---|---|
lahsab_intent_id | L’intent que l’acheteur vient de payer. |
lahsab_invoice_id | La facture rattachée, s’il y en a une. |
lahsab_status | L’état au moment du clic (proof_submitted, confirmed, paid…). |
De quoi afficher « on a bien reçu votre reçu, la confirmation arrive » plutôt qu’une page d’accueil muette. Ces paramètres ne sont ni signés ni authentifiés, c’est du confort d’affichage : tout ce qui engage votre application passe par le webhook.
Applications mobiles
Préférez un lien universel (https://app.votre-site.dz/paiement/retour) : il ouvre votre application quand elle est installée, retombe sur votre site sinon, et passe la liste blanche comme n’importe quelle URL. Un schéma custom (votreapp://retour) fonctionne aussi, à condition de le déclarer dans vos domaines autorisés.
Paiements
Créer un intent
/payments/intents Crée une intention d’encaissement et renvoie un checkoutUrl, le lien de paiement hébergé, valable 48 h. Redirigez l’acheteur dessus.
| Champ | Type | Requis | Détail |
|---|---|---|---|
amount | int | Oui | Entier strictement positif (DZD). |
description | string | Non | Libellé montré à l’acheteur sur la page de paiement (280 car. max). Sans lui, l’acheteur ne voit qu’un montant nu. |
customerRef | string | Non | Votre référence (ex. identifiant utilisateur). Jamais exposée à l’acheteur. |
method | string | Non | ccp ou baridimob : présélectionne le moyen de paiement sur la page (l’acheteur peut en changer). 400 PAYMENT_METHOD_NOT_AVAILABLE si ce moyen n’est pas offert par votre configuration. |
locale | string | Non | fr, ar ou en : la langue de la page de paiement. Voir Langue du checkout. Défaut : votre langue par défaut. |
returnUrl | string | Non | Où renvoyer l’acheteur depuis la page. Le domaine doit figurer dans votre liste blanche. Voir Retour vers votre site. |
metadata | object | Non | Objet JSON libre. Jamais exposé à l’acheteur. |
Au moins un moyen d’encaissement doit être activé et complet dans vos réglages (BaridiMob : titulaire + RIP ; CCP : titulaire + numéro + clé + adresse), sinon 409 MERCHANT_PAYMENT_DETAILS_MISSING. C’est le cas dans les deux environnements, sandbox comprise : sans coordonnées, l’acheteur n’aurait nulle part où virer l’argent, et un test qui saute cette étape ne testerait pas le vrai parcours.
Erreurs : 400 VALIDATION_ERROR, 400 PAYMENT_METHOD_NOT_AVAILABLE, 400 MERCHANT_LOCALE_NOT_AVAILABLE, 400 MERCHANT_RETURN_URL_NOT_ALLOWED, 401 AUTH_UNAUTHORIZED, 409 MERCHANT_PAYMENT_DETAILS_MISSING.
curl -X POST https://api.lahsab.com/payments/intents \
-H "x-api-key: lsk_live_CLE" \
-H "content-type: application/json" \
-d '{
"amount": 2500,
"customerRef": "user_abc123",
"metadata": { "orderId": "CMD-1042" }
}' const res = await fetch("https://api.lahsab.com/payments/intents", {
method: "POST",
headers: {
"x-api-key": process.env.LAHSAB_API_KEY,
"content-type": "application/json",
},
body: JSON.stringify({
amount: 2500,
customerRef: "user_abc123",
metadata: { orderId: "CMD-1042" },
}),
});
const intent = await res.json();
window.location.href = intent.checkoutUrl; Réponse 201
{
"id": "a7f3c2e1-9b4d-4e6a-8c1f-2d3e4f5a6b7c",
"environment": "live",
"customerRef": "user_abc123",
"amount": 2500,
"locale": "fr",
"returnUrl": "https://votre-site.dz/merci",
"status": "pending",
"checkoutUrl": "https://dashboard.lahsab.com/pay/a7f3c2e1-9b4d-4e6a-8c1f-2d3e4f5a6b7c",
"metadata": { "orderId": "CMD-1042" },
"expiresAt": "2026-07-13T10:00:00.000Z",
"proofs": [],
"version": 1,
"createdAt": "2026-07-11T10:00:00.000Z",
"updatedAt": "2026-07-11T10:00:00.000Z"
} Consulter un intent
/payments/intents/:id Retourne l’état courant, si l’intent appartient à la clé et à son environnement. Sinon 404 : un intent d’un autre environnement est invisible.
Erreurs : 401 AUTH_UNAUTHORIZED, 404 PAYMENT_INTENT_NOT_FOUND.
curl https://api.lahsab.com/payments/intents/INTENT_ID \
-H "x-api-key: lsk_live_CLE" Réponse 200
{
"id": "a7f3c2e1-9b4d-4e6a-8c1f-2d3e4f5a6b7c",
"environment": "live",
"amount": 2500,
"method": "baridimob",
"status": "proof_submitted",
"checkoutUrl": "https://dashboard.lahsab.com/pay/a7f3c2e1-9b4d-4e6a-8c1f-2d3e4f5a6b7c",
"expiresAt": "2026-07-13T10:00:00.000Z",
"proofs": [
{
"id": "9c8b7a6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d",
"originalName": "recu-baridimob.jpg",
"mimeType": "image/jpeg",
"size": 184230,
"method": "baridimob",
"submittedAt": "2026-07-11T10:12:00.000Z"
}
],
"version": 2,
"createdAt": "2026-07-11T10:00:00.000Z",
"updatedAt": "2026-07-11T10:12:00.000Z"
} Page de paiement
/payments/checkout/:id Aucune authentification : c’est ce que la page hébergée sert au navigateur de l’acheteur. Renvoie une vue publique de l’intent, le montant, l’état, et les moyens d’encaissement du marchand dans methods, chacun avec ses propres coordonnées (RIP pour BaridiMob, numéro + clé + adresse pour le versement CCP). Seuls les moyens activés et complets apparaissent. method reflète la présélection éventuelle ; l’acheteur choisit son moyen sur la page. locale est la langue dans laquelle la page se rend, locales la liste de vos langues activées. Jamais de clé API, de secret webhook, ni de metadata.
Un intent expiré (au-delà de 48 h) est renvoyé avec status: "expired".
Cette vue ne distingue pas un intent dont la preuve a été jugée cohérente d’un intent dont la preuve vient d’arriver : les deux sortent en proof_submitted. C’est délibéré. L’acheteur ne doit pas apprendre qu’un fichier a passé l’analyse, sinon le dépôt devient un banc d’essai pour fabriquer un faux qui passe. L’état réel, proof_validated, ne circule que sur les surfaces marchand : GET /payments/intents/:id, le webhook et le tableau de bord.
Erreurs : 404 PAYMENT_INTENT_NOT_FOUND.
curl https://api.lahsab.com/payments/checkout/INTENT_ID Réponse 200
{
"id": "a7f3c2e1-9b4d-4e6a-8c1f-2d3e4f5a6b7c",
"environment": "live",
"amount": 2500,
"method": "baridimob",
"locale": "ar",
"locales": ["fr", "ar"],
"status": "pending",
"expiresAt": "2026-07-13T10:00:00.000Z",
"hasProof": false,
"merchantConfigured": true,
"methods": [
{
"method": "ccp",
"holder": "SARL ACME",
"ccpNumber": "0123456789",
"ccpKey": "12",
"address": "10 rue Didouche Mourad, Alger"
},
{
"method": "baridimob",
"holder": "SARL ACME",
"rip": "00799999012345678912"
}
],
"merchant": {
"displayName": "ACME Store",
"paymentInstructions": "Le versement doit venir de votre propre compte.",
"supportEmail": "support@acme.dz",
"supportPhone": "0770000000"
}
} Déposer une preuve
/payments/intents/:id/proof Aucune authentification : endpoint public, utilisé par la page de paiement (ou votre propre page si vous en construisez une). Envoi multipart/form-data, champ file + champ method (ccp ou baridimob), le moyen que l’acheteur a réellement utilisé. method peut être omis quand un seul moyen est offert ou que l’intent porte une présélection valide. L’intent passe en proof_submitted et porte désormais ce method ; le webhook payment.proof_submitted le transporte.
Le fichier est borné : images (JPEG, PNG, WebP, HEIC) ou PDF, 10 Mo max.
Erreurs : 400 PAYMENT_PROOF_FILE_MISSING, 400 PAYMENT_METHOD_REQUIRED, 400 PAYMENT_METHOD_NOT_AVAILABLE, 404 PAYMENT_INTENT_NOT_FOUND, 409 PAYMENT_INVALID_TRANSITION, 409 PAYMENT_INTENT_EXPIRED, 413 FILE_TOO_LARGE, 415 FILE_TYPE_NOT_ALLOWED, 429 PAYMENT_PROOF_LIMIT_REACHED.
curl -X POST https://api.lahsab.com/payments/intents/INTENT_ID/proof \
-F "method=baridimob" \
-F "file=@recu-baridimob.jpg" Réponse 201 : la vue publique de l’intent (même forme que la page de paiement), avec hasProof: true et status: "proof_submitted". Aucune donnée sensible.
Valider une preuve
/payments/intents/:id/validate-proof Scope payments:confirm. Marque la preuve comme cohérente : l’intent passe de proof_submitted à proof_validated et l’événement payment.proof_validated part. C’est le geste manuel équivalent au verdict de l’analyse automatique, utile quand celle-ci a rendu suspect et que vous jugez la preuve bonne, ou quand elle n’a pas pu tourner.
Rien n’est déclaré sur l’argent. L’intent n’est pas confirmé, la facture rattachée n’est pas soldée. La transition est idempotente et monotone : rejouée, elle renvoie l’intent tel quel.
Erreurs : 403 API_KEY_SCOPE_MISSING, 404 PAYMENT_INTENT_NOT_FOUND, 409 PAYMENT_INVALID_TRANSITION (aucune preuve déposée, ou intent déjà tranché).
curl -X POST https://api.lahsab.com/payments/intents/INTENT_ID/validate-proof \
-H "x-api-key: lsk_live_CLE_CONFIRM" Réponse 201 : l’intent complet, status: "proof_validated", proofValidationSource: "manual".
Confirmer un paiement
/payments/intents/:id/confirm Scope payments:confirm. Déclare que l’argent est arrivé. L’intent passe en confirmed, la facture rattachée est soldée, et les webhooks payment.confirmed puis invoice.paid partent. C’est l’appel le plus lourd de conséquences de toute l’API : chez vous, il ouvre un accès ou fait partir un colis.
lahsab ne voit jamais votre compte CCP. La confirmation reste donc un constat que quelqu’un fait à votre place : un humain dans le tableau de bord, ou le service qui lit vos SMS de versement. Confirmer sur la seule foi d’une preuve déposée par l’acheteur revient à croire le débiteur sur parole.
Idempotent : un intent déjà confirmed est renvoyé tel quel. Un intent rejected ou expired répond 409.
Erreurs : 403 API_KEY_SCOPE_MISSING, 403 MERCHANT_ACCESS_DENIED, 404 PAYMENT_INTENT_NOT_FOUND, 409 PAYMENT_INVALID_TRANSITION.
# Un humain a constaté le versement, ou votre service de reconciliation l'a fait pour vous.
curl -X POST https://api.lahsab.com/payments/intents/INTENT_ID/confirm \
-H "x-api-key: lsk_live_CLE_CONFIRM" Réponse 201 : l’intent complet, status: "confirmed", confirmationSource: "manual".
Rejeter un paiement
/payments/intents/:id/reject Scope payments:confirm. Ferme l’intent en rejected et émet payment.rejected. Le champ reason est optionnel, il est conservé sur l’intent.
Erreurs : 403 API_KEY_SCOPE_MISSING, 404 PAYMENT_INTENT_NOT_FOUND, 409 PAYMENT_INVALID_TRANSITION.
curl -X POST https://api.lahsab.com/payments/intents/INTENT_ID/reject \
-H "x-api-key: lsk_live_CLE_CONFIRM" \
-H "content-type: application/json" \
-d '{ "reason": "Montant du recu inferieur au montant attendu" }' Réponse 201 : l’intent complet, status: "rejected".
Télécharger un justificatif
/payments/proofs/:id/file Scope proofs:read, distinct de payments:read. Renvoie le fichier déposé par l’acheteur, avec son content-type d’origine. L’identifiant de la preuve se lit dans proofs[] sur l’intent, ou dans le webhook payment.proof_submitted.
Ce scope est séparé parce qu’un reçu CCP porte un nom, un numéro de compte et un montant : ce sont des données personnelles, pas un statut de paiement. Un tableau de bord analytique n’en a pas besoin.
La preuve d’un autre marchand répond 403 MERCHANT_ACCESS_DENIED ; celle d’un autre environnement, 404 PAYMENT_PROOF_NOT_FOUND.
curl https://api.lahsab.com/payments/proofs/PROOF_ID/file \
-H "x-api-key: lsk_live_CLE_PREUVES" \
-o recu.jpg Réponse 200 : le fichier brut (image ou PDF).
Simulation (sandbox)
Des endpoints réservés à la sandbox pour forcer chaque scénario sans attendre un vrai acheteur. Ils traversent la vraie machine à états, le vrai pipeline de confirmation, et émettent les vrais webhooks : parité totale avec la production. Appelés avec une clé lsk_live_… : 400 ENVIRONMENT_NOT_ALLOWED. Le mode d’emploi complet est dans Tester en sandbox.
Simuler une preuve
/payments/intents/:id/simulate/proof Attache une preuve synthétique (un PDF « PREUVE SIMULEE - SANDBOX », visible dans le tableau de bord comme n’importe quelle preuve) et passe l’intent en proof_submitted. Émet payment.proof_submitted, puis payment.proof_analyzed avec une analyse simulée dont le verdict est match, puis payment.proof_validated puisque ce verdict est cohérent. Le paiement, lui, reste à confirmer : aucun automate ne le confirme, exactement comme en production.
Mêmes gardes que le dépôt public : un intent expiré répond 409 PAYMENT_INTENT_EXPIRED, un état terminal 409 PAYMENT_INVALID_TRANSITION.
Erreurs : 400 ENVIRONMENT_NOT_ALLOWED, 401 AUTH_UNAUTHORIZED, 404 PAYMENT_INTENT_NOT_FOUND, 409 PAYMENT_INVALID_TRANSITION, 409 PAYMENT_INTENT_EXPIRED.
curl -X POST https://api.lahsab.com/payments/intents/INTENT_ID/simulate/proof \
-H "x-api-key: lsk_sandbox_CLE" Réponse 201 : l’intent mis à jour, status à proof_submitted, puis proof_validated une fois l’analyse rendue.
Simuler la confirmation
/payments/intents/:id/simulate/confirm La confirmation « argent reçu », par le pipeline de signal habituel : l’intent passe en confirmed, le payload du webhook payment.confirmed porte confirmationSource: "manual", comme en production. Si l’intent est rattaché à une facture, elle est soldée : invoice.paid est émis et la période de l’abonnement avance.
Valide depuis pending, proof_submitted ou proof_validated. Idempotent : rappelé sur un intent déjà confirmé, il renvoie l’intent confirmé, sans erreur.
Erreurs : 400 ENVIRONMENT_NOT_ALLOWED, 401 AUTH_UNAUTHORIZED, 404 PAYMENT_INTENT_NOT_FOUND, 409 PAYMENT_INVALID_TRANSITION.
curl -X POST https://api.lahsab.com/payments/intents/INTENT_ID/simulate/confirm \
-H "x-api-key: lsk_sandbox_CLE" Réponse 201 : l’intent mis à jour, status à confirmed.
Simuler un rejet ou une expiration
/payments/intents/:id/simulate/reject /payments/intents/:id/simulate/expire reject rejette le paiement : l’intent passe en rejected, payment.rejected est émis. Le scénario « preuve déposée mais fonds jamais reçus » s’obtient en enchaînant simulate/proof puis simulate/reject.
| Champ | Type | Requis | Détail |
|---|---|---|---|
reason | string | Non | Motif du rejet. |
expire force l’expiration sans attendre expiresAt. Valide uniquement depuis pending, sinon 409 PAYMENT_INVALID_TRANSITION. Émet payment.expired.
Erreurs : 400 ENVIRONMENT_NOT_ALLOWED, 401 AUTH_UNAUTHORIZED, 404 PAYMENT_INTENT_NOT_FOUND, 409 PAYMENT_INVALID_TRANSITION.
# Rejeter : le motif est optionnel.
curl -X POST https://api.lahsab.com/payments/intents/INTENT_ID/simulate/reject \
-H "x-api-key: lsk_sandbox_CLE" \
-H "content-type: application/json" \
-d '{ "reason": "Fonds jamais reçus" }'
# Expirer (uniquement depuis pending).
curl -X POST https://api.lahsab.com/payments/intents/INTENT_ID/simulate/expire \
-H "x-api-key: lsk_sandbox_CLE" Réponse 201 : l’intent mis à jour, status à rejected ou expired.
Faire tourner l’horloge
/billing/simulate/tick Exécute l’horloge de facturation maintenant, bornée à vos données sandbox (renouvellements, retards, expirations), et renvoie son rapport. C’est ce qui permet d’observer plusieurs cycles d’un abonnement interval: "day" en quelques minutes.
purgedIdempotencyKeys vaut toujours 0 en mode borné. Limité à 6 appels par minute.
Erreurs : 400 ENVIRONMENT_NOT_ALLOWED, 401 AUTH_UNAUTHORIZED, 429 RATE_LIMITED.
curl -X POST https://api.lahsab.com/billing/simulate/tick \
-H "x-api-key: lsk_sandbox_CLE" Réponse : le rapport d’exécution.
{
"expiredIntents": 0,
"issuedInvoices": 1,
"markedOverdue": 0,
"markedUncollectible": 0,
"closedSubscriptions": 0,
"purgedIdempotencyKeys": 0
} Repartir de zéro
/payments/sandbox-data Efface vos données de test (intents, preuves, événements). Sandbox uniquement : avec une clé lsk_live_…, 400 ENVIRONMENT_NOT_ALLOWED. Utile entre deux passes de test pour repartir sur une base propre.
Erreurs : 400 ENVIRONMENT_NOT_ALLOWED, 401 AUTH_UNAUTHORIZED.
curl -X DELETE https://api.lahsab.com/payments/sandbox-data \
-H "x-api-key: lsk_sandbox_CLE" Réponse 200 : { "success": true }.
Clients
Créer un client
/customers Un client est la contrepartie qui paie. Tout le reste (abonnements, factures) s’y rattache.
| Champ | Type | Requis | Détail |
|---|---|---|---|
externalId | string | Non | Votre identifiant client. Unique par marchand et environnement. |
name | string | Non | Nom affiché sur la facture. |
email | string | Non | |
phone | string | Non | |
defaultMethod | string | Non | ccp ou baridimob. Présélectionne le moyen de paiement sur les factures de ce client, seulement s’il est offert par votre configuration. |
locale | string | Non | fr, ar ou en : la langue des pages envoyées à ce client, renouvellements d’abonnement compris. Voir Langue du checkout. |
metadata | object | Non | Objet JSON libre. |
Renseignez externalId : c’est lui qui vous revient dans chaque webhook, et il vous évite de stocker l’id lahsab de votre côté.
Erreurs : 400 VALIDATION_ERROR, 400 MERCHANT_LOCALE_NOT_AVAILABLE, 401 AUTH_UNAUTHORIZED, 409 CUSTOMER_ALREADY_EXISTS, 409 IDEMPOTENCY_KEY_REUSED.
curl -X POST https://api.lahsab.com/customers \
-H "x-api-key: lsk_live_CLE" \
-H "content-type: application/json" \
-H "Idempotency-Key: cust-user_abc123" \
-d '{
"externalId": "user_abc123",
"name": "Yacine B.",
"email": "yacine@example.dz",
"phone": "0770000000",
"defaultMethod": "baridimob"
}' Réponse 201
{
"id": "c0ffee00-1111-4222-8333-444455556666",
"environment": "live",
"externalId": "user_abc123",
"name": "Yacine B.",
"email": "yacine@example.dz",
"phone": "0770000000",
"defaultMethod": "baridimob",
"locale": "ar",
"version": 1,
"createdAt": "2026-07-13T10:00:00.000Z",
"updatedAt": "2026-07-13T10:00:00.000Z"
} Lire et mettre à jour un client
/customers /customers/:id /customers/:id GET /customers?externalId= est le motif de lookup / upsert : cherchez par votre propre identifiant, créez si la liste revient vide. C’est ainsi qu’on résout un client sans jamais stocker d’id lahsab.
Sans filtre, GET /customers liste les clients de l’environnement courant.
PATCH accepte name, email, phone, defaultMethod, locale et metadata. L’externalId n’est pas modifiable. Passez "locale": null pour revenir à votre langue par défaut.
Erreurs : 401 AUTH_UNAUTHORIZED, 404 CUSTOMER_NOT_FOUND.
# Lookup par votre propre identifiant : le motif upsert.
curl "https://api.lahsab.com/customers?externalId=user_abc123" \
-H "x-api-key: lsk_live_CLE"
curl https://api.lahsab.com/customers/CUSTOMER_ID \
-H "x-api-key: lsk_live_CLE"
curl -X PATCH https://api.lahsab.com/customers/CUSTOMER_ID \
-H "x-api-key: lsk_live_CLE" \
-H "content-type: application/json" \
-d '{ "phone": "0771111111" }' Réponse 200 : un tableau pour la liste, l’objet client pour les deux autres.
Produits et prix
Produits
/products /products /products/:id Un produit, c’est ce que vous vendez, pas son tarif. Le tarif, c’est le prix (ci-dessous), et un produit peut en porter plusieurs.
| Champ | Type | Requis | Détail |
|---|---|---|---|
name | string | Oui | « Abonnement Pro ». |
description | string | Non | |
metadata | object | Non | Objet JSON libre. |
PATCH accepte name, description, active et metadata. Passez active: false pour retirer un produit du catalogue sans casser l’historique.
Erreurs : 400 VALIDATION_ERROR, 401 AUTH_UNAUTHORIZED, 404 PRODUCT_NOT_FOUND.
curl -X POST https://api.lahsab.com/products \
-H "x-api-key: lsk_live_CLE" \
-H "content-type: application/json" \
-H "Idempotency-Key: prod-pro" \
-d '{ "name": "Abonnement Pro", "description": "Accès complet" }'
curl https://api.lahsab.com/products \
-H "x-api-key: lsk_live_CLE"
curl -X PATCH https://api.lahsab.com/products/PRODUCT_ID \
-H "x-api-key: lsk_live_CLE" \
-H "content-type: application/json" \
-d '{ "name": "Abonnement Pro (mensuel)" }' Réponse 201
{
"id": "3b2a1c09-8d7e-4f65-a432-1098765fedcb",
"environment": "live",
"name": "Abonnement Pro",
"description": "Accès complet",
"active": true,
"version": 1,
"createdAt": "2026-07-13T10:00:00.000Z",
"updatedAt": "2026-07-13T10:00:00.000Z"
} Créer un prix
/prices Un prix attache un montant et une cadence à un produit. C’est lui qu’on souscrit.
| Champ | Type | Requis | Détail |
|---|---|---|---|
productId | uuid | Oui | Le produit tarifé. |
amount | int | Oui | Entier strictement positif (DZD). |
type | string | Oui | one_time ou recurring. |
interval | string | Si recurring | day, week, month, year. |
intervalCount | int | Non | Multiplicateur de l’intervalle (1 à 52). Défaut 1. |
trialPeriodDays | int | Non | Essai gratuit (0 à 365). |
nickname | string | Non | Libellé interne. |
metadata | object | Non | Objet JSON libre. |
Un abonnement trimestriel s’exprime interval: "month" + intervalCount: 3.
Erreurs : 400 VALIDATION_ERROR, 401 AUTH_UNAUTHORIZED, 404 PRODUCT_NOT_FOUND, 409 IDEMPOTENCY_KEY_REUSED.
curl -X POST https://api.lahsab.com/prices \
-H "x-api-key: lsk_live_CLE" \
-H "content-type: application/json" \
-H "Idempotency-Key: price-pro-mensuel-2500" \
-d '{
"productId": "3b2a1c09-8d7e-4f65-a432-1098765fedcb",
"amount": 2500,
"type": "recurring",
"interval": "month",
"intervalCount": 1,
"nickname": "Pro mensuel"
}' Réponse 201
{
"id": "9f8e7d6c-5b4a-4321-9876-543210fedcba",
"environment": "live",
"productId": "3b2a1c09-8d7e-4f65-a432-1098765fedcb",
"productName": "Abonnement Pro",
"nickname": "Pro mensuel",
"amount": 2500,
"type": "recurring",
"interval": "month",
"intervalCount": 1,
"active": true,
"version": 1,
"createdAt": "2026-07-13T10:00:00.000Z",
"updatedAt": "2026-07-13T10:00:00.000Z"
} Lister et archiver un prix
/prices /prices/:id GET /prices?productId=&active=true liste le catalogue vendable d’un produit.
PATCH n’accepte donc que nickname, active et metadata. Archiver un prix ne touche pas aux abonnements déjà souscrits dessus : ils continuent de facturer au montant convenu.
Erreurs : 400 VALIDATION_ERROR, 401 AUTH_UNAUTHORIZED, 404 PRICE_NOT_FOUND.
curl "https://api.lahsab.com/prices?productId=PRODUCT_ID&active=true" \
-H "x-api-key: lsk_live_CLE"
# Archiver un tarif : on ne le modifie pas, on le retire du catalogue.
curl -X PATCH https://api.lahsab.com/prices/PRICE_ID \
-H "x-api-key: lsk_live_CLE" \
-H "content-type: application/json" \
-d '{ "active": false }' Réponse 200 : le prix mis à jour, active à false.
Coupons
/coupons /coupons /coupons/:id Un coupon définit une réduction réutilisable, en pourcentage ou en montant fixe. Il s’applique à la souscription (champ couponId de POST /subscriptions) et module le montant de chaque facture selon sa duration.
| Champ | Type | Requis | Détail |
|---|---|---|---|
name | string | Oui | Affiché à l’acheteur sur la facture et le checkout. |
percentOff | int | L’un des deux | 1 à 100. Exclusif avec amountOff. |
amountOff | int | L’un des deux | Entier strictement positif (DZD). Exclusif avec percentOff. |
duration | string | Oui | forever (toutes les factures) ou once (la première uniquement). |
metadata | object | Non | Objet JSON libre. |
Archiver un coupon (active: false) le retire des nouvelles souscriptions. Les abonnements qui le portent déjà gardent leur réduction : l’engagement pris court toujours.
Un coupon en pourcentage traverse les changements de prix : -50 % reste -50 % quel que soit le tarif souscrit ensuite.
Chaque facture réduite porte son instantané d’audit : amountSubtotal (le montant plein), discountAmount (la réduction appliquée), couponId et couponName. La facture reste auto-explicative même si le coupon est archivé ou renommé ensuite.
Erreurs : 400 VALIDATION_ERROR, 401 AUTH_UNAUTHORIZED, 404 COUPON_NOT_FOUND, 409 IDEMPOTENCY_KEY_REUSED.
curl -X POST https://api.lahsab.com/coupons \
-H "x-api-key: lsk_live_CLE" \
-H "content-type: application/json" \
-H "Idempotency-Key: coupon-founder" \
-d '{
"name": "founder",
"percentOff": 50,
"duration": "forever"
}'
curl "https://api.lahsab.com/coupons?active=true" \
-H "x-api-key: lsk_live_CLE"
# Archiver un coupon : il ne s'applique plus aux nouveaux abonnements.
curl -X PATCH https://api.lahsab.com/coupons/COUPON_ID \
-H "x-api-key: lsk_live_CLE" \
-H "content-type: application/json" \
-d '{ "active": false }' Réponse 201
{
"id": "7d6c5b4a-3210-4fed-9cba-876543210fed",
"environment": "live",
"name": "founder",
"percentOff": 50,
"duration": "forever",
"active": true,
"version": 1,
"createdAt": "2026-07-23T10:00:00.000Z",
"updatedAt": "2026-07-23T10:00:00.000Z"
} Abonnements
Souscrire un abonnement
/subscriptions Le point d’entrée du parcours d’achat. Un seul appel : l’abonnement est créé, la première facture est émise, et la réponse porte le lien de paiement. C’est l’équivalent d’un Stripe Checkout.
| Champ | Type | Requis | Détail |
|---|---|---|---|
customerId | uuid | Oui | Le client qui souscrit. |
priceId | uuid | Oui | Doit être un prix recurring et active. |
daysUntilDue | int | Non | Délai de paiement accordé (0 à 365). Défaut 7. |
invoiceLeadDays | int | Non | Combien de jours avant la fin de période la facture de renouvellement est émise. Défaut 7. |
trialPeriodDays | int | Non | Essai gratuit. Prime sur celui du prix. |
cancelAt | datetime | Non | Terme fixe : l’abonnement se termine à cette date, quel que soit l’intervalle du prix. Doit être dans le futur (et après la fin d’essai, le cas échéant). |
couponId | uuid | Non | Applique un coupon actif : chaque facture est réduite selon sa duration. Un seul coupon par abonnement. |
locale | string | Non | fr, ar ou en : épingle la langue de toutes les factures de cet abonnement, renouvellements compris. Sans lui, chaque facture relit la langue du client. |
returnUrl | string | Non | Où renvoyer l’acheteur depuis la page. Le domaine doit figurer dans votre liste blanche. Voir Retour vers votre site. |
metadata | object | Non | Objet JSON libre. |
L’abonnement naît incomplete : redirigez l’acheteur vers latestInvoice.hostedInvoiceUrl. Il passera active à la réception de invoice.paid, jamais avant.
Avec un essai : pas de facture immédiate. L’abonnement naît trialing, currentPeriodEnd vaut trialEnd, et latestInvoice est absent.
Avec cancelAt : les périodes avancent normalement, mais chaque nouvelle période s’arrête au plus tard à cancelAt. La dernière est donc tronquée, sa facture affiche la vraie période, et aucun renouvellement n’est émis au-delà. Le cas type : un pass annuel vendu en septembre qui se termine le 30 juin, en une seule facture.
Erreurs : 400 VALIDATION_ERROR, 400 SUBSCRIPTION_CANCEL_AT_INVALID, 400 MERCHANT_LOCALE_NOT_AVAILABLE, 400 MERCHANT_RETURN_URL_NOT_ALLOWED, 400 COUPON_ARCHIVED, 401 AUTH_UNAUTHORIZED, 404 CUSTOMER_NOT_FOUND, 404 PRICE_NOT_FOUND, 404 COUPON_NOT_FOUND, 400 PRICE_INACTIVE, 400 PRICE_NOT_RECURRING, 409 MERCHANT_PAYMENT_DETAILS_MISSING, 409 IDEMPOTENCY_KEY_REUSED.
curl -X POST https://api.lahsab.com/subscriptions \
-H "x-api-key: lsk_live_CLE" \
-H "content-type: application/json" \
-H "Idempotency-Key: sub-user_abc123-pro-mensuel" \
-d '{
"customerId": "c0ffee00-1111-4222-8333-444455556666",
"priceId": "9f8e7d6c-5b4a-4321-9876-543210fedcba",
"daysUntilDue": 7,
"invoiceLeadDays": 7
}'
# Terme fixe + réduction : un pass saison à -50 %, qui se termine le 30 juin.
curl -X POST https://api.lahsab.com/subscriptions \
-H "x-api-key: lsk_live_CLE" \
-H "content-type: application/json" \
-H "Idempotency-Key: sub-user_abc123-pass-saison" \
-d '{
"customerId": "c0ffee00-1111-4222-8333-444455556666",
"priceId": "PASS_SAISON_PRICE_ID",
"cancelAt": "2027-06-30T00:00:00.000Z",
"couponId": "COUPON_ID"
}' const res = await fetch("https://api.lahsab.com/subscriptions", {
method: "POST",
headers: {
"x-api-key": process.env.LAHSAB_API_KEY,
"content-type": "application/json",
"Idempotency-Key": `sub-${user.id}-pro-mensuel`,
},
body: JSON.stringify({
customerId: customer.id,
priceId: PRO_MENSUEL,
daysUntilDue: 7,
}),
});
const subscription = await res.json();
window.location.href = subscription.latestInvoice.hostedInvoiceUrl; Réponse 201
{
"id": "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d",
"environment": "live",
"status": "incomplete",
"collectionMethod": "send_invoice",
"daysUntilDue": 7,
"invoiceLeadDays": 7,
"currentPeriodStart": "2026-07-13T00:00:00.000Z",
"currentPeriodEnd": "2026-08-13T00:00:00.000Z",
"cancelAtPeriodEnd": false,
"latestInvoice": {
"id": "f1e2d3c4-b5a6-4978-8b1c-2d3e4f5a6b7c",
"number": "LSB-2026-0042",
"status": "open",
"amountDue": 2500,
"dueDate": "2026-07-20T00:00:00.000Z",
"hostedInvoiceUrl": "https://dashboard.lahsab.com/invoice/f1e2d3c4-b5a6-4978-8b1c-2d3e4f5a6b7c",
"version": 2,
"createdAt": "2026-07-13T10:00:00.000Z",
"updatedAt": "2026-07-13T10:00:00.000Z"
},
"version": 2,
"createdAt": "2026-07-13T10:00:00.000Z",
"updatedAt": "2026-07-13T10:00:00.000Z"
} Consulter un abonnement
/subscriptions /subscriptions/:id Filtres : customerId, status.
currentPeriodEnd est la seule date qui compte : c’est « payé jusque-là ». Elle n’avance que lorsque la facture de la période suivante est payée, jamais sur une facture simplement émise. Voir la règle d’or.
L’abonnement expose aussi cancelAt (le terme fixe, s’il y en a un) et discount.coupon (le coupon appliqué), en API comme dans les payloads webhook.
Erreurs : 401 AUTH_UNAUTHORIZED, 404 SUBSCRIPTION_NOT_FOUND.
curl "https://api.lahsab.com/subscriptions?customerId=CUSTOMER_ID&status=active" \
-H "x-api-key: lsk_live_CLE"
curl https://api.lahsab.com/subscriptions/SUBSCRIPTION_ID \
-H "x-api-key: lsk_live_CLE" Réponse 200 : l’abonnement, customer, price et latestInvoice inclus.
Annuler et reprendre
/subscriptions/:id/cancel /subscriptions/:id/resume | Champ | Type | Requis | Détail |
|---|---|---|---|
atPeriodEnd | bool | Non | Défaut true. |
atPeriodEnd: true (le défaut) programme l’annulation : cancelAtPeriodEnd passe à true, l’abonnement reste active jusqu’au bout de la période déjà payée, et aucune facture de renouvellement ne sera émise. C’est le comportement à vouloir : le client a payé jusqu’à currentPeriodEnd, il en a le droit.
atPeriodEnd: false termine immédiatement : canceled, endedAt posé. La période payée est perdue.
resume revient sur une annulation programmée, tant que la période court encore. Un abonnement déjà canceled ne se reprend pas : 409 SUBSCRIPTION_INVALID_TRANSITION.
Erreurs : 401 AUTH_UNAUTHORIZED, 404 SUBSCRIPTION_NOT_FOUND, 409 SUBSCRIPTION_ALREADY_CANCELED, 409 SUBSCRIPTION_INVALID_TRANSITION.
# Par défaut : à la fin de la période déjà payée.
curl -X POST https://api.lahsab.com/subscriptions/SUBSCRIPTION_ID/cancel \
-H "x-api-key: lsk_live_CLE" \
-H "content-type: application/json" \
-d '{ "atPeriodEnd": true }'
# Revenir sur une annulation programmée.
curl -X POST https://api.lahsab.com/subscriptions/SUBSCRIPTION_ID/resume \
-H "x-api-key: lsk_live_CLE" Réponse 201
{
"id": "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d",
"status": "active",
"cancelAtPeriodEnd": true,
"canceledAt": "2026-07-13T10:00:00.000Z",
"currentPeriodEnd": "2026-08-13T00:00:00.000Z"
} Factures
Créer une facture ponctuelle
/invoices Une facture hors abonnement : une prestation, un acompte, une commande. Elle est finalisée (donc numérotée) immédiatement, et hostedInvoiceUrl est la page à envoyer au client.
| Champ | Type | Requis | Détail |
|---|---|---|---|
customerId | uuid | Oui | |
amount | int | Oui | Entier strictement positif (DZD). |
description | string | Non | Ce qui est facturé (280 car. max). |
dueDate | date | Non | Échéance (YYYY-MM-DD). Défaut : 2 jours. |
locale | string | Non | fr, ar ou en : la langue de la facture hébergée. Défaut : la langue du client, sinon la vôtre. |
returnUrl | string | Non | Où renvoyer l’acheteur depuis la page. Le domaine doit figurer dans votre liste blanche. Voir Retour vers votre site. |
Les factures d’abonnement, elles, sont émises automatiquement, vous n’avez pas à les créer.
Erreurs : 400 VALIDATION_ERROR, 400 MERCHANT_LOCALE_NOT_AVAILABLE, 400 MERCHANT_RETURN_URL_NOT_ALLOWED, 401 AUTH_UNAUTHORIZED, 404 CUSTOMER_NOT_FOUND, 409 MERCHANT_PAYMENT_DETAILS_MISSING, 409 IDEMPOTENCY_KEY_REUSED.
curl -X POST https://api.lahsab.com/invoices \
-H "x-api-key: lsk_live_CLE" \
-H "content-type: application/json" \
-H "Idempotency-Key: inv-CMD-1042" \
-d '{
"customerId": "c0ffee00-1111-4222-8333-444455556666",
"amount": 12000,
"description": "Installation sur site",
"dueDate": "2026-07-31"
}' Réponse 201
{
"id": "a1b2c3d4-e5f6-4789-8abc-def012345678",
"environment": "live",
"customerId": "c0ffee00-1111-4222-8333-444455556666",
"number": "LSB-2026-0043",
"status": "open",
"amountDue": 12000,
"amountPaid": 0,
"description": "Installation sur site",
"locale": "fr",
"dueDate": "2026-07-31T00:00:00.000Z",
"finalizedAt": "2026-07-13T10:00:00.000Z",
"hostedInvoiceUrl": "https://dashboard.lahsab.com/invoice/a1b2c3d4-e5f6-4789-8abc-def012345678",
"version": 2,
"createdAt": "2026-07-13T10:00:00.000Z",
"updatedAt": "2026-07-13T10:00:00.000Z"
} Consulter une facture
/invoices /invoices/:id Filtres : customerId, subscriptionId, status.
GET /invoices?status=open est votre liste d’impayés. status=uncollectible est votre liste de pertes.
Une facture d’abonnement porte periodStart / periodEnd, et un checkoutUrl tant qu’elle a un intent payable en cours.
Erreurs : 401 AUTH_UNAUTHORIZED, 404 INVOICE_NOT_FOUND.
curl "https://api.lahsab.com/invoices?customerId=CUSTOMER_ID&status=open" \
-H "x-api-key: lsk_live_CLE"
curl https://api.lahsab.com/invoices/INVOICE_ID \
-H "x-api-key: lsk_live_CLE" Réponse 200 : la facture, ou un tableau pour la liste.
Annuler
/invoices/:id/void void annule une facture open : elle n’est plus payable, et elle garde son numéro (une séquence légale ne se réutilise pas). Une facture déjà payée ne s’annule pas (409 INVOICE_ALREADY_PAID).
Erreurs : 401 AUTH_UNAUTHORIZED, 404 INVOICE_NOT_FOUND, 409 INVOICE_ALREADY_PAID, 409 INVOICE_INVALID_TRANSITION.
# Annuler une facture ouverte.
curl -X POST https://api.lahsab.com/invoices/INVOICE_ID/void \
-H "x-api-key: lsk_live_CLE" Réponse 201 : la facture mise à jour (status à void).
Page de facture
/invoices/:id/public Aucune authentification : c’est ce que la page de facture hébergée (/invoice/:id) sert au navigateur de votre client. C’est le lien qu’on colle dans un WhatsApp.
Renvoie une vue publique : le montant, l’échéance, le statut, et vos moyens d’encaissement dans methods (même forme que la page de paiement, moyens activés et complets seulement). locale et locales pilotent la langue, comme sur la page de paiement. Jamais de clé, de secret, de metadata ni d’externalId.
Erreurs : 404 INVOICE_NOT_FOUND.
curl https://api.lahsab.com/invoices/INVOICE_ID/public Réponse 200
{
"id": "f1e2d3c4-b5a6-4978-8b1c-2d3e4f5a6b7c",
"number": "LSB-2026-0042",
"status": "open",
"environment": "live",
"amountDue": 2500,
"amountPaid": 0,
"locale": "ar",
"locales": ["fr", "ar"],
"dueDate": "2026-07-20T00:00:00.000Z",
"customerName": "Yacine B.",
"hasProof": false,
"merchantConfigured": true,
"methods": [
{
"method": "baridimob",
"holder": "SARL ACME",
"rip": "00799999012345678912"
}
],
"merchant": {
"displayName": "ACME Store",
"supportEmail": "support@acme.dz"
}
} Portail client
Ouvrir une session de portail
/portal-sessions Renvoie une URL où votre client retrouve son abonnement, ses factures, et un bouton pour payer. La page « gérer mon abonnement » que vous n’avez pas à construire.
| Champ | Type | Requis | Détail |
|---|---|---|---|
customerId | uuid | Oui | |
returnUrl | string | Non | Où renvoyer l’acheteur depuis la page. Le domaine doit figurer dans votre liste blanche. Voir Retour vers votre site. |
Le lien vit 1 heure. La page elle-même (/portal/:token) est publique, ouverte par le porteur du lien.
Erreurs : 400 VALIDATION_ERROR, 400 MERCHANT_RETURN_URL_NOT_ALLOWED, 401 AUTH_UNAUTHORIZED, 404 CUSTOMER_NOT_FOUND.
curl -X POST https://api.lahsab.com/portal-sessions \
-H "x-api-key: lsk_live_CLE" \
-H "content-type: application/json" \
-d '{ "customerId": "c0ffee00-1111-4222-8333-444455556666" }' Réponse 201
{
"url": "https://dashboard.lahsab.com/portal/ps_7f3c2e1a9b4d4e6a8c1f2d3e",
"expiresAt": "2026-07-13T11:00:00.000Z"
} Enveloppe d’erreur
Toutes les erreurs partagent la même forme. Testez l’errorCode (stable), pas le message.
{
"statusCode": 404,
"errorCode": "PAYMENT_INTENT_NOT_FOUND",
"message": "Payment intent not found",
"timestamp": "2026-07-11T10:00:00.000Z",
"path": "/payments/intents/a7f3c2e1-9b4d-4e6a-8c1f-2d3e4f5a6b7c"
}
errorCode | Statut | Signification |
|---|---|---|
VALIDATION_ERROR | 400 | Corps de requête invalide, y compris un PATCH sur un champ immuable d’un prix. |
PAYMENT_PROOF_FILE_MISSING | 400 | Aucun fichier de preuve. |
PAYMENT_METHOD_REQUIRED | 400 | Plusieurs moyens offerts : la preuve doit préciser method. |
PAYMENT_METHOD_NOT_AVAILABLE | 400 | Moyen de paiement désactivé ou non configuré chez le marchand. |
MERCHANT_LOCALE_NOT_AVAILABLE | 400 | Langue demandée non activée dans vos réglages. |
MERCHANT_RETURN_URL_NOT_ALLOWED | 400 | returnUrl hors de votre liste de domaines autorisés, ou d’une forme non acceptée. |
MERCHANT_RETURN_URL_INVALID | 400 | Adresse de retour d’une forme non acceptée, à l’enregistrement de vos réglages. |
ENVIRONMENT_NOT_ALLOWED | 400 | Endpoint sandbox appelé avec une clé live. |
PRICE_INACTIVE | 400 | Ce prix est archivé, il ne se souscrit plus. |
PRICE_NOT_RECURRING | 400 | Un abonnement exige un prix recurring. |
AUTH_UNAUTHORIZED | 401 | Clé API manquante ou invalide. |
PAYMENT_INTENT_NOT_FOUND | 404 | Intent inconnu, d’un autre marchand ou environnement. |
CUSTOMER_NOT_FOUND | 404 | Client inconnu. |
PRODUCT_NOT_FOUND | 404 | Produit inconnu. |
PRICE_NOT_FOUND | 404 | Prix inconnu. |
SUBSCRIPTION_NOT_FOUND | 404 | Abonnement inconnu. |
INVOICE_NOT_FOUND | 404 | Facture inconnue. |
PAYMENT_INVALID_TRANSITION | 409 | Transition d’état impossible. |
PAYMENT_INTENT_EXPIRED | 409 | Lien de paiement expiré (au-delà de 48 h). |
MERCHANT_PAYMENT_DETAILS_MISSING | 409 | Aucun moyen d’encaissement activé et complet. |
CUSTOMER_ALREADY_EXISTS | 409 | Cet externalId est déjà pris dans cet environnement. |
SUBSCRIPTION_ALREADY_CANCELED | 409 | Abonnement déjà terminé. |
SUBSCRIPTION_INVALID_TRANSITION | 409 | Transition d’état impossible. |
INVOICE_ALREADY_PAID | 409 | Une facture payée ne s’annule pas. |
INVOICE_INVALID_TRANSITION | 409 | Transition d’état impossible. |
IDEMPOTENCY_KEY_REUSED | 409 | Même Idempotency-Key, corps différent. |
IDEMPOTENCY_KEY_IN_FLIGHT | 409 | La requête d’origine est encore en cours. Réessayez. |
FILE_TOO_LARGE | 413 | Fichier de preuve trop volumineux (10 Mo max). |
FILE_TYPE_NOT_ALLOWED | 415 | Fichier de preuve d’un type non accepté (images ou PDF). |
PAYMENT_PROOF_LIMIT_REACHED | 429 | Trop de preuves déposées sur cet intent. |
RATE_LIMITED | 429 | Trop de requêtes. |