sdk-php (PHP)
À quoi ça sert
Section intitulée « À quoi ça sert »sdk-php est le client HTTP officiel en PHP pour parler à l’API REST Titreo E-Pay (/v1/*). Il joue exactement le même rôle que sdk-js, mais côté serveur PHP : c’est la couche qui sait construire les requêtes authentifiées, gérer l’idempotence et les rejeux, parser les réponses et vérifier les webhooks entrants.
Le public visé : les plugins CMS (PrestaShop, WooCommerce, Magento) et les intégrateurs PHP custom (apps Symfony / Laravel) qui doivent piloter des sessions de paiement depuis leur backend.
Le principe directeur est la pure couche transport : aucune logique métier, aucun ORM, aucun cache. Le SDK ne décide rien sur l’orchestration (le split-tender, le rollback, le séquencement des legs sont la responsabilité du backend) ; il se contente de transporter des appels et de renvoyer des tableaux PHP associatifs. Le contrat de surface est aligné sur celui du SDK JS — voir Contrats inter-briques.
use TitreoEpay\Sdk\Client;use TitreoEpay\Sdk\Configuration;
$client = new Client(new Configuration( apiKey: 'tep_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', baseUrl: 'https://api.titreo-epay.io',));
// 1. Créer une session de paiement (Idempotency-Key auto)$session = $client->sessions->create([ 'reference' => 'ORDER-12345', 'totalAmount' => 5000, // centimes 'currency' => 'EUR',]);
// 2. Ajouter une jambe carte cadeau$client->sessions->addGiftLeg($session['id'], [ 'cardToken' => 'tok_xxx', 'cardLast4' => '1234', 'emitter' => 'titreo', 'availableBalance' => 2000,]);
// 3. Définir la jambe PSP pour le reste à payer$client->sessions->setPspLeg($session['id'], [ 'providerType' => 'stripe', 'paymentMethod' => 'card',]);
// 4. Soumettre — déclenche l'orchestration côté backend$result = $client->sessions->submit($session['id']);Stack et compatibilité
Section intitulée « Stack et compatibilité »| Élément | Valeur |
|---|---|
| Langage | PHP 8.2+ (constructor property promotion, readonly, enums, match) |
| Namespace racine | TitreoEpay\Sdk\ (autoload PSR-4 sur sdk-php/src/) |
| Package Composer | titreo-epay/sdk-php |
| Contrat HTTP | PSR-7 (messages) + PSR-17 (factories) + PSR-18 (client) |
| Découverte client/factory | php-http/discovery (HTTPlug discovery) |
| Tests | PHPUnit 11 |
| Analyse statique | PHPStan level 7 sur src/ |
| Style | PHP-CS-Fixer (@PSR12 + @PHP82Migration, strict types imposés) |
Carte des fichiers
Section intitulée « Carte des fichiers »Tous les chemins sont relatifs à sdk-php/.
| Fichier | Classe / rôle |
|---|---|
src/Client.php |
Client — façade. Instancie le HttpClient et expose les ressources comme propriétés publiques readonly. |
src/Configuration.php |
Configuration — objet de config immuable (apiKey, baseUrl, client/factories PSR optionnels, timeoutSeconds, maxRetries, autoIdempotency, userAgent). Valide apiKey et baseUrl non vides. |
src/Http/HttpClient.php |
HttpClient — cœur transport : construction de requête, auth Bearer, idempotence, retry/backoff, parsing, mapping des erreurs. |
src/Http/IdempotencyKey.php |
IdempotencyKey — génère un identifiant type UUID v7 (timestamp + aléatoire) pour les clés d’idempotence auto. |
src/Resource/Sessions.php |
Sessions — create, get, list, addGiftLeg, setPspLeg, removeLeg, submit, refund, bootstrap. |
src/Resource/Me.php |
Me — get() sur /v1/me (introspection de la clé API : merchant, scopes). |
src/Resource/MerchantsMe.php |
MerchantsMe — get, update du marchand courant (/v1/merchants/me, dont webhookUrl / webhookSecret). |
src/Resource/ApiKeys.php |
ApiKeys — create, list, revoke des sous-clés scopées. |
src/Resource/PspConfigs.php |
PspConfigs — list, get, upsert, delete des credentials PSP par provider. |
src/Resource/AuditLogs.php |
AuditLogs — list() paginée des journaux d’audit. |
src/Resource/AdminMerchants.php |
AdminMerchants — create, list, get, update (console interne, scope admin). |
src/Webhook/Verifier.php |
Verifier — vérification HMAC-SHA256 timing-safe des webhooks sortants reçus par le marchand. |
src/Webhook/VerificationResult.php |
VerificationResult — résultat immuable (valid, reason, timestamp), construit par fabriques statiques. |
src/Exception/TitreoEpayException.php |
TitreoEpayException — racine de toutes les exceptions SDK (étend \RuntimeException). |
src/Exception/ApiException.php |
ApiException — erreur HTTP 4xx/5xx (status, apiCode, details, helpers isClientError() / isServerError()). |
src/Exception/IdempotencyConflictException.php |
IdempotencyConflictException — sous-type d’ApiException figé sur HTTP 422 / IDEMPOTENCY_KEY_CONFLICT. |
src/Exception/NetworkException.php |
NetworkException — échec transport (timeout, DNS, retries épuisés). |
tests/ClientTest.php |
Tests d’intégration sur un client PSR-18 factice (Bearer, idempotence auto/explicite, erreurs, 204, query arrays). |
tests/Webhook/VerifierTest.php |
Tests de la vérification webhook (signature valide, falsifiée, header malformé, expiration, mauvais secret). |
Architecture interne
Section intitulée « Architecture interne »flowchart TD
A["Code appelant (plugin CMS / app PHP)"] --> B["Client"]
B --> C["Sessions / Me / MerchantsMe / ApiKeys / PspConfigs / AuditLogs / AdminMerchants"]
C --> D["HttpClient"]
D --> E["Discovery PSR-18 / PSR-17 (php-http/discovery)"]
E --> F["Client HTTP de l'hôte (Guzzle, Symfony HttpClient, ...)"]
F --> G["API Titreo E-Pay /v1/*"]
D -. "erreurs" .-> H["ApiException / IdempotencyConflictException / NetworkException"]
Le Client n’est qu’un assembleur : son constructeur crée un unique HttpClient à partir de la Configuration, puis instancie chaque ressource en lui passant ce HttpClient partagé. Chaque classe Resource/* est un mince traducteur « méthode métier → appel HTTP » ; toute la mécanique réseau vit dans HttpClient.
Abstraction PSR-7 / PSR-17 / PSR-18 et discovery
Section intitulée « Abstraction PSR-7 / PSR-17 / PSR-18 et discovery »Le SDK n’impose aucun client HTTP. Il dépend uniquement des interfaces standard :
Psr\Http\Client\ClientInterface(PSR-18) — pour envoyer les requêtes ;Psr\Http\Message\RequestFactoryInterfaceetStreamFactoryInterface(PSR-17) — pour fabriquer requêtes et corps ;- les messages PSR-7 produits par ces factories.
Si l’application ne fournit pas son propre client/factories dans la Configuration, HttpClient les auto-découvre via php-http/discovery (Psr18ClientDiscovery::find(), Psr17FactoryDiscovery::findRequestFactory() / findStreamFactory()). Concrètement : installer Guzzle (ou Symfony HttpClient) à côté du SDK suffit, la découverte le branche automatiquement.
// Cas 1 — auto-discovery : on laisse php-http/discovery trouver Guzzle installé$client = new Client(new Configuration( apiKey: 'tep_live_…', baseUrl: 'https://api.titreo-epay.io',));
// Cas 2 — on injecte explicitement un client + des factories PSR (ex. en test, ou client configuré sur mesure)$psr17 = new \GuzzleHttp\Psr7\HttpFactory();$client = new Client(new Configuration( apiKey: 'tep_live_…', baseUrl: 'https://api.titreo-epay.io', httpClient: $monClientPsr18, requestFactory: $psr17, streamFactory: $psr17,));L’injection explicite est exactement ce qu’utilise tests/ClientTest.php : un ClientInterface anonyme capture chaque requête et renvoie une réponse Guzzle PSR-7 simulée, sans réseau.
composer.json déclare allow-plugins: { "php-http/discovery": true } pour que le plugin de découverte fonctionne à l’installation.
Construction de la requête
Section intitulée « Construction de la requête »Pour chaque appel, HttpClient::request() :
- construit l’URL :
rtrim(baseUrl, '/')+path, puis sérialise la query (encodagerawurlencode, et répétition de la clé pour les valeurs tableau, ex.status=COMPLETED&status=FAILED) ; - pose
Authorization: Bearer <apiKey>,Accept: application/jsonetUser-Agent; - si un corps est fourni, l’encode en JSON (
JSON_THROW_ON_ERROR) et poseContent-Type: application/json; - parse la réponse 2xx en tableau associatif (
json_decode(..., associative: true)), avec 204 →null.
Idempotence
Section intitulée « Idempotence »L’idempotence est un invariant non négociable du système (voir Sécurité). Le SDK l’applique automatiquement sur toute méthode non sûre (tout sauf GET / HEAD / OPTIONS).
- Si l’appelant passe
idempotencyKey, cette valeur est utilisée telle quelle. - Sinon, et si
autoIdempotencyvauttrue(défaut),IdempotencyKey::generate()produit une clé type UUID v7 (timestamp en tête de bytes + version0x7+ variant0x8). - Si
autoIdempotencyvautfalseet qu’aucune clé n’est fournie, aucun en-têteIdempotency-Keyn’est posé.
// Clé auto (UUID v7) — pratique pour des scripts ponctuels$client->sessions->create([ 'reference' => 'ORDER-1', 'totalAmount' => 1000, 'currency' => 'EUR',]);
// Clé explicite — RECOMMANDÉ pour les plugins CMS : dérivée de la commande,// elle rend le rejeu sûr si le client recharge la page de paiement.$client->sessions->create( ['reference' => 'ORDER-2', 'totalAmount' => 1000, 'currency' => 'EUR'], idempotencyKey: 'create-order-2',);Côté serveur, le rejeu d’une même clé avec le même corps renvoie la réponse stockée ; une même clé avec un corps différent provoque une IdempotencyConflictException (HTTP 422, code IDEMPOTENCY_KEY_CONFLICT).
Retry et backoff
Section intitulée « Retry et backoff »HttpClient réessaie jusqu’à maxRetries fois (défaut 3) :
- sur erreur transport (
ClientExceptionInterface) : retry, puisNetworkExceptionune fois le quota épuisé ; - sur réponse 5xx : retry uniquement si la méthode est sûre ou si la requête porte un en-tête
Idempotency-Key(un POST sans clé n’est jamais rejoué automatiquement, pour éviter un double encaissement).
Le backoff est exponentiel avec jitter (200ms · 2^tentative, plafonné à 5 s, + 0–100 ms aléatoires).
Vérification des webhooks
Section intitulée « Vérification des webhooks »Quand le backend Titreo E-Pay notifie le marchand (session complétée, échouée, remboursée…), il signe le corps brut. Le plugin CMS doit vérifier cette signature avant de traiter l’événement, sinon n’importe qui pourrait forger une notification « commande payée ».
Webhook\Verifier fait cette vérification en HMAC-SHA256, avec comparaison timing-safe (hash_equals) pour ne pas fuiter d’information par mesure de temps.
- Header attendu :
t=<unix_ts>,v1=<hex>(parsé par regex stricte ; tout autre format →malformed_header). - Entrée du MAC :
"<timestamp>.<rawBody>"— d’où l’importance d’utiliser le corps brut non re-sérialisé. - Fenêtre anti-rejeu :
toleranceSeconds(défaut 300 s). Au-delà →expired.
use TitreoEpay\Sdk\Webhook\Verifier;
// $merchantWebhookSecret = la valeur configurée via merchantsMe->update(['webhookSecret' => ...])$verifier = new Verifier(secret: $merchantWebhookSecret, toleranceSeconds: 300);
$rawBody = file_get_contents('php://input');$header = $_SERVER['HTTP_X_TITREO_SIGNATURE'] ?? '';
$result = $verifier->verify($rawBody, $header);if (!$result->valid) { // $result->reason ∈ { 'malformed_header', 'expired', 'invalid_signature' } http_response_code(400); return;}
$event = json_decode($rawBody, associative: true);match ($event['event']) { 'session.completed' => $this->markOrderPaid($event['session']['reference']), 'session.failed' => $this->markOrderFailed($event['session']['reference']), 'session.refunded', 'session.partially_refunded' => $this->markOrderRefunded($event), default => null,};VerificationResult est immuable : son constructeur est privé, et il s’obtient via les fabriques valid(int $timestamp), malformedHeader(), expired(), invalidSignature(). En cas de succès, $result->timestamp porte l’horodatage signé.
sequenceDiagram
participant API as "API Titreo E-Pay"
participant EP as "Endpoint webhook (plugin CMS)"
participant V as "Webhook\Verifier"
API->>EP: "POST rawBody + header x-titreo-signature"
EP->>V: "verify(rawBody, header)"
V->>V: "parse t/v1, contrôle tolérance, hash_equals"
V-->>EP: "VerificationResult"
alt valid
EP->>EP: "json_decode + traitement métier (markOrderPaid, ...)"
EP-->>API: "200 OK"
else invalide / expiré / malformé
EP-->>API: "400"
end
Gestion des erreurs
Section intitulée « Gestion des erreurs »Toutes les exceptions descendent de TitreoEpayException (elle-même \RuntimeException), ce qui permet un catch large ou ciblé.
use TitreoEpay\Sdk\Exception\ApiException;use TitreoEpay\Sdk\Exception\IdempotencyConflictException;use TitreoEpay\Sdk\Exception\NetworkException;
try { $client->sessions->submit($sessionId);} catch (IdempotencyConflictException $e) { // Même clé d'idempotence, corps différent (HTTP 422). À traiter avant ApiException : c'est un sous-type.} catch (ApiException $e) { // $e->status (int), $e->apiCode (string), $e->details (mixed), $e->getMessage() if ($e->isClientError()) { /* 4xx : requête à corriger */ } if ($e->isServerError()) { /* 5xx : retries déjà épuisés côté SDK */ }} catch (NetworkException $e) { // timeout, DNS, transport indisponible}HttpClient::toApiException() lit le corps d’erreur de l’API (code, message, details). À défaut de code, il fabrique HTTP_<status> ; à défaut de message, il reprend la reason phrase HTTP. Le code spécial IDEMPOTENCY_KEY_CONFLICT est promu en IdempotencyConflictException.
flowchart TD
A["TitreoEpayException (RuntimeException)"]
A --> B["ApiException (status, apiCode, details)"]
A --> C["NetworkException"]
B --> D["IdempotencyConflictException (422, IDEMPOTENCY_KEY_CONFLICT)"]
Référence des ressources
Section intitulée « Référence des ressources »Endpoints réellement appelés (préfixe /v1).
| Ressource | Méthode SDK | Verbe + chemin |
|---|---|---|
Me |
get() |
GET /v1/me |
Sessions |
create($input, ?$idem) |
POST /v1/sessions |
Sessions |
get($id) |
GET /v1/sessions/{id} |
Sessions |
list($query) |
GET /v1/sessions |
Sessions |
addGiftLeg($id, $input, ?$idem) |
POST /v1/sessions/{id}/legs/gift |
Sessions |
setPspLeg($id, $input, ?$idem) |
POST /v1/sessions/{id}/legs/psp |
Sessions |
removeLeg($id, $legId, ?$idem) |
DELETE /v1/sessions/{id}/legs/{legId} |
Sessions |
submit($id, ?$idem) |
POST /v1/sessions/{id}/submit |
Sessions |
refund($id, $input, ?$idem) |
POST /v1/sessions/{id}/refund |
Sessions |
bootstrap($id) |
GET /v1/sessions/{id}/bootstrap |
MerchantsMe |
get() / update($input, ?$idem) |
GET / PUT /v1/merchants/me |
ApiKeys |
create / list / revoke |
POST / GET /v1/merchants/me/api-keys, DELETE …/{id} |
PspConfigs |
list / get / upsert / delete |
…/me/psp-configs[/{provider}] |
AuditLogs |
list($query) |
GET /v1/audit-logs |
AdminMerchants |
create / list / get / update |
…/v1/admin/merchants[/{id}] |
Les méthodes de liste paginées renvoient la forme { data: [...], nextCursor: ?string, hasMore: bool }.
La clé API porte des scopes ; un appel hors scope est rejeté par l’API (4xx). On peut inspecter les scopes de la clé courante via $client->me->get()['scopes'].
| Scope | Permet |
|---|---|
sessions:read |
lecture des sessions, bootstrap |
sessions:write |
création/suppression de sessions et legs, submit, refund |
psp:read |
lecture des psp-configs |
psp:write |
upsert / delete des psp-configs |
webhooks:write |
PUT /v1/merchants/me (URL + secret webhook) |
admin |
ressources AdminMerchants (console interne Titreo) |
Installation et scripts
Section intitulée « Installation et scripts »# Installer le SDK dans une app / un plugincomposer require titreo-epay/sdk-php
# Fournir un client HTTP (auto-discovery) — au choixcomposer require guzzlehttp/guzzleScripts définis dans composer.json (à lancer depuis sdk-php/) :
| Commande | Effet |
|---|---|
composer test |
PHPUnit (suite tests/) |
composer phpstan |
phpstan analyse (level 7 sur src/) |
composer cs-fix |
PHP-CS-Fixer en mode correction |
composer cs-check |
PHP-CS-Fixer en --dry-run --diff (CI) |
Outillage de dev (déclaré en require-dev) : guzzlehttp/guzzle + guzzlehttp/psr7 (client/factories PSR pour les tests), phpunit/phpunit ^11, phpstan/phpstan ^1.12, friendsofphp/php-cs-fixer ^3.65.
Versioning
Section intitulée « Versioning »SemVer aligné sur l’API : 1.x.y ↔ API /v1. Côté consommateur, on épingle "titreo-epay/sdk-php": "^1.0". C’est cette brique que les plugins PHP consomment comme dépendance Composer pour rester découplés du backend — voir la Vue d’ensemble de l’écosystème.
Pour aller plus loin
Section intitulée « Pour aller plus loin »- sdk-js (JS/TS) — équivalent côté navigateur / Node, même surface de contrat
- Contrats inter-briques — ce qui est garanti entre SDK et backend
- Sécurité — idempotence, signatures webhook, gestion des secrets
- Backend — l’orchestrateur que ce SDK pilote
- Environnement de dev local — faire tourner l’API en local pour tester le SDK