Aller au contenu

sdk-php (PHP)

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']);
É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)

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 Sessionscreate, get, list, addGiftLeg, setPspLeg, removeLeg, submit, refund, bootstrap.
src/Resource/Me.php Meget() sur /v1/me (introspection de la clé API : merchant, scopes).
src/Resource/MerchantsMe.php MerchantsMeget, update du marchand courant (/v1/merchants/me, dont webhookUrl / webhookSecret).
src/Resource/ApiKeys.php ApiKeyscreate, list, revoke des sous-clés scopées.
src/Resource/PspConfigs.php PspConfigslist, get, upsert, delete des credentials PSP par provider.
src/Resource/AuditLogs.php AuditLogslist() paginée des journaux d’audit.
src/Resource/AdminMerchants.php AdminMerchantscreate, 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).
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.

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\RequestFactoryInterface et StreamFactoryInterface (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.

Pour chaque appel, HttpClient::request() :

  1. construit l’URL : rtrim(baseUrl, '/') + path, puis sérialise la query (encodage rawurlencode, et répétition de la clé pour les valeurs tableau, ex. status=COMPLETED&status=FAILED) ;
  2. pose Authorization: Bearer <apiKey>, Accept: application/json et User-Agent ;
  3. si un corps est fourni, l’encode en JSON (JSON_THROW_ON_ERROR) et pose Content-Type: application/json ;
  4. parse la réponse 2xx en tableau associatif (json_decode(..., associative: true)), avec 204 → null.

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 autoIdempotency vaut true (défaut), IdempotencyKey::generate() produit une clé type UUID v7 (timestamp en tête de bytes + version 0x7 + variant 0x8).
  • Si autoIdempotency vaut false et qu’aucune clé n’est fournie, aucun en-tête Idempotency-Key n’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).

HttpClient réessaie jusqu’à maxRetries fois (défaut 3) :

  • sur erreur transport (ClientExceptionInterface) : retry, puis NetworkException une 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).

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

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)"]

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)
Fenêtre de terminal
# Installer le SDK dans une app / un plugin
composer require titreo-epay/sdk-php
# Fournir un client HTTP (auto-discovery) — au choix
composer require guzzlehttp/guzzle

Scripts 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.

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.