Tableau de bord ↗ العربية

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 PaymentIntent pour 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.

ScopeCe qu’il ouvre
payments:readLire les intents et le résumé d’encaissement.
payments:writeCréer des intents, purger la sandbox, appeler les endpoints de simulation.
payments:confirmTrancher le sort d’un paiement : confirmer, rejeter, valider une preuve.
proofs:readTélécharger le justificatif déposé par l’acheteur.
billing:readLire clients, produits, prix, coupons, factures, abonnements.
billing:writeCréer et modifier le catalogue, les abonnements, les factures.
webhooks:readLire le journal des livraisons, écouter le flux sandbox (lahsab listen).
webhooks:writeRenvoyer 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.

TraficLimite
Appels portant une clé API1200 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.

CasRéponse
Même clé, même corpsLa réponse d’origine, rejouée.
Même clé, corps différent409 IDEMPOTENCY_KEY_REUSED.
Même clé, requête d’origine encore en cours409 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 :

  1. le locale passé à la création de l’objet,
  2. le locale du client rattaché,
  3. votre langue par défaut,
  4. 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 faitesCe 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 /subscriptionsL’abonnement est épinglé : toutes ses factures gardent cette langue, quoi que devienne le client.
Ni l’un ni l’autreVotre 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églageRôle
Adresse de retour par défautUtilisée quand votre appel API ne transmet rien. Laissez vide pour n’afficher aucun bouton.
Domaines autorisésLa 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 :

  1. la returnUrl de l’objet,
  2. celle de la facture dont l’intent découle, sinon celle de l’abonnement dont la facture découle,
  3. votre adresse de retour par défaut,
  4. 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ètreContenu
lahsab_intent_idL’intent que l’acheteur vient de payer.
lahsab_invoice_idLa facture rattachée, s’il y en a une.
lahsab_statusL’é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

POST /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.

ChampTypeRequisDétail
amountintOuiEntier strictement positif (DZD).
descriptionstringNonLibellé montré à l’acheteur sur la page de paiement (280 car. max). Sans lui, l’acheteur ne voit qu’un montant nu.
customerRefstringNonVotre référence (ex. identifiant utilisateur). Jamais exposée à l’acheteur.
methodstringNonccp 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.
localestringNonfr, ar ou en : la langue de la page de paiement. Voir Langue du checkout. Défaut : votre langue par défaut.
returnUrlstringNonOù renvoyer l’acheteur depuis la page. Le domaine doit figurer dans votre liste blanche. Voir Retour vers votre site.
metadataobjectNonObjet 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.

Requête
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" }
  }'

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

GET /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.

Requête
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

GET /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.

Requête
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

POST /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.

Requête
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

POST /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é).

Requête
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

POST /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.

Requête
# 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

POST /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.

Requête
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

GET /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.

Requête
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

POST /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.

Requête
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

POST /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.

Requête
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

POST /payments/intents/:id/simulate/reject
POST /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.

ChampTypeRequisDétail
reasonstringNonMotif 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.

Requêtes
# 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

POST /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.

Requête
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

DELETE /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.

Requête
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

POST /customers

Un client est la contrepartie qui paie. Tout le reste (abonnements, factures) s’y rattache.

ChampTypeRequisDétail
externalIdstringNonVotre identifiant client. Unique par marchand et environnement.
namestringNonNom affiché sur la facture.
emailstringNon
phonestringNon
defaultMethodstringNonccp ou baridimob. Présélectionne le moyen de paiement sur les factures de ce client, seulement s’il est offert par votre configuration.
localestringNonfr, ar ou en : la langue des pages envoyées à ce client, renouvellements d’abonnement compris. Voir Langue du checkout.
metadataobjectNonObjet 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.

Requête
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

GET /customers
GET /customers/:id
PATCH /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.

Requêtes
# 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

POST /products
GET /products
PATCH /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.

ChampTypeRequisDétail
namestringOui« Abonnement Pro ».
descriptionstringNon
metadataobjectNonObjet 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.

Requêtes
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

POST /prices

Un prix attache un montant et une cadence à un produit. C’est lui qu’on souscrit.

ChampTypeRequisDétail
productIduuidOuiLe produit tarifé.
amountintOuiEntier strictement positif (DZD).
typestringOuione_time ou recurring.
intervalstringSi recurringday, week, month, year.
intervalCountintNonMultiplicateur de l’intervalle (1 à 52). Défaut 1.
trialPeriodDaysintNonEssai gratuit (0 à 365).
nicknamestringNonLibellé interne.
metadataobjectNonObjet 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.

Requête
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

GET /prices
PATCH /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.

Requêtes
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

POST /coupons
GET /coupons
PATCH /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.

ChampTypeRequisDétail
namestringOuiAffiché à l’acheteur sur la facture et le checkout.
percentOffintL’un des deux1 à 100. Exclusif avec amountOff.
amountOffintL’un des deuxEntier strictement positif (DZD). Exclusif avec percentOff.
durationstringOuiforever (toutes les factures) ou once (la première uniquement).
metadataobjectNonObjet 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.

Requêtes
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

POST /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.

ChampTypeRequisDétail
customerIduuidOuiLe client qui souscrit.
priceIduuidOuiDoit être un prix recurring et active.
daysUntilDueintNonDélai de paiement accordé (0 à 365). Défaut 7.
invoiceLeadDaysintNonCombien de jours avant la fin de période la facture de renouvellement est émise. Défaut 7.
trialPeriodDaysintNonEssai gratuit. Prime sur celui du prix.
cancelAtdatetimeNonTerme 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).
couponIduuidNonApplique un coupon actif : chaque facture est réduite selon sa duration. Un seul coupon par abonnement.
localestringNonfr, ar ou en : épingle la langue de toutes les factures de cet abonnement, renouvellements compris. Sans lui, chaque facture relit la langue du client.
returnUrlstringNonOù renvoyer l’acheteur depuis la page. Le domaine doit figurer dans votre liste blanche. Voir Retour vers votre site.
metadataobjectNonObjet 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.

Requête
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"
  }'

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

GET /subscriptions
GET /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.

Requêtes
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

POST /subscriptions/:id/cancel
POST /subscriptions/:id/resume
ChampTypeRequisDétail
atPeriodEndboolNonDé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.

Requêtes
# 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

POST /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.

ChampTypeRequisDétail
customerIduuidOui
amountintOuiEntier strictement positif (DZD).
descriptionstringNonCe qui est facturé (280 car. max).
dueDatedateNonÉchéance (YYYY-MM-DD). Défaut : 2 jours.
localestringNonfr, ar ou en : la langue de la facture hébergée. Défaut : la langue du client, sinon la vôtre.
returnUrlstringNonOù 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.

Requête
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

GET /invoices
GET /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.

Requêtes
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

POST /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.

Requête
# 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

GET /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.

Requête
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

POST /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.

ChampTypeRequisDétail
customerIduuidOui
returnUrlstringNonOù 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.

Requête
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"
}
errorCodeStatutSignification
VALIDATION_ERROR400Corps de requête invalide, y compris un PATCH sur un champ immuable d’un prix.
PAYMENT_PROOF_FILE_MISSING400Aucun fichier de preuve.
PAYMENT_METHOD_REQUIRED400Plusieurs moyens offerts : la preuve doit préciser method.
PAYMENT_METHOD_NOT_AVAILABLE400Moyen de paiement désactivé ou non configuré chez le marchand.
MERCHANT_LOCALE_NOT_AVAILABLE400Langue demandée non activée dans vos réglages.
MERCHANT_RETURN_URL_NOT_ALLOWED400returnUrl hors de votre liste de domaines autorisés, ou d’une forme non acceptée.
MERCHANT_RETURN_URL_INVALID400Adresse de retour d’une forme non acceptée, à l’enregistrement de vos réglages.
ENVIRONMENT_NOT_ALLOWED400Endpoint sandbox appelé avec une clé live.
PRICE_INACTIVE400Ce prix est archivé, il ne se souscrit plus.
PRICE_NOT_RECURRING400Un abonnement exige un prix recurring.
AUTH_UNAUTHORIZED401Clé API manquante ou invalide.
PAYMENT_INTENT_NOT_FOUND404Intent inconnu, d’un autre marchand ou environnement.
CUSTOMER_NOT_FOUND404Client inconnu.
PRODUCT_NOT_FOUND404Produit inconnu.
PRICE_NOT_FOUND404Prix inconnu.
SUBSCRIPTION_NOT_FOUND404Abonnement inconnu.
INVOICE_NOT_FOUND404Facture inconnue.
PAYMENT_INVALID_TRANSITION409Transition d’état impossible.
PAYMENT_INTENT_EXPIRED409Lien de paiement expiré (au-delà de 48 h).
MERCHANT_PAYMENT_DETAILS_MISSING409Aucun moyen d’encaissement activé et complet.
CUSTOMER_ALREADY_EXISTS409Cet externalId est déjà pris dans cet environnement.
SUBSCRIPTION_ALREADY_CANCELED409Abonnement déjà terminé.
SUBSCRIPTION_INVALID_TRANSITION409Transition d’état impossible.
INVOICE_ALREADY_PAID409Une facture payée ne s’annule pas.
INVOICE_INVALID_TRANSITION409Transition d’état impossible.
IDEMPOTENCY_KEY_REUSED409Même Idempotency-Key, corps différent.
IDEMPOTENCY_KEY_IN_FLIGHT409La requête d’origine est encore en cours. Réessayez.
FILE_TOO_LARGE413Fichier de preuve trop volumineux (10 Mo max).
FILE_TYPE_NOT_ALLOWED415Fichier de preuve d’un type non accepté (images ou PDF).
PAYMENT_PROOF_LIMIT_REACHED429Trop de preuves déposées sur cet intent.
RATE_LIMITED429Trop de requêtes.