Les points clés de ce retour d'expérience :
- Automatisation KYC : Remplacement d’un processus manuel (upload local de pièces + modération EasyAdmin) par l'intégration d'un tiers spécialisé (Veriff), réduisant le délai de validation de 24 heures à moins de 2 minutes.
- Sécurisation des Webhooks : Double contrôle des requêtes entrantes via l’en-tête x-auth-client et vérification d’intégrité par signature HMAC SHA-256 (X-Hmac-Signature).
- Architecture découplée Task / TaskObject : Centralisation du traitement métier des changements de statut dans UpdateUserKycTask piloté par le DTO UpdateUserKycTo.
- Synchronisation d'avatar asynchrone : Extraction automatique et récupération de la photo du visage (face / face-pre) via l'API Media de Veriff et Symfony Messenger.
- Verrouillage des statuts terminaux & webhooks hors ordre : Protection contre l’écrasement des profils validés (VERIFIED) par des événements reçus en retard ou en doublon.
- Normalisation des données identitaires : Formatage des noms et prénoms extraits des pièces d'identité par translittération iconv (ASCII//TRANSLIT).
1. Contexte & Problématique Métier
Sur une plateforme de réservation d'hébergements entre particuliers, la confiance entre hôtes et locataires est indispensable. Avant d'autoriser le déblocage de versements ou d'accorder l'accès à certaines démarches sensibles (signature de bail, messagerie), l'identité des membres doit être vérifiée (processus Know Your Customer - KYC).
Historiquement, la vérification d'identité reposait sur un fonctionnement interne : l'utilisateur envoyait une copie de sa pièce d'identité et un selfie sur l'application. Ces pièces alimentaient une file d'attente dans le back-office EasyAdmin, où une équipe contrôlait manuellement chaque document.
Les limites de l'ancien système : En plus du temps passé en modération, les délais de traitement variaient entre 12 et 48 heures (surtout le week-end), ce qui bloquait les utilisateurs dans leur réservation. Côté RGPD et sécurité, le fait de stocker directement des pièces d'identité en base de données posait aussi des contraintes de sécurité et de durée de rétention.
Note importante et confidentialité : Pour respecter mes obligations de confidentialité et préserver la propriété intellectuelle, l'ensemble des extraits et exemples de code présentés ici a été entièrement recréé et anonymisé. De ce fait, des imprécisions ou de légères erreurs peuvent exister dans les exemples : le but de cet article est d'illustrer la démarche d'architecture et la réflexion technique, et non de fournir du code prêt à l'emploi.
2. Comparatif Avant / Après
Pour éviter ces délais, j'ai mis en place l'intégration avec Veriff pour automatiser la vérification d'identité avec analyse biométrique.
| Axe d'analyse | Avant (Modération Manuelle Internalisée) | Après (Intégration API & Webhooks Veriff) |
|---|---|---|
| Délai de validation (SLA) | 12h à 48h (selon les horaires de modération) | < 2 minutes (disponible 24/7) |
| Sécurité & Données personnelles | Documents stockés localement sur nos serveurs | Aucun stockage de document brut sur notre infrastructure |
| Expérience Utilisateur (UX) | Formulaire statique avec envoi de fichiers | Parcours guidé web et mobile avec capture directe |
| Architecture de code | Contrôleur monolithe et couplage fort | Architecture découplée Task / TaskObject multi-provider |
3. Architecture Cible & Flux de Données
Le nouveau flux sépare bien le client web/mobile, l'API Veriff et notre domaine Symfony :
4. Modélisation des sessions & client API Veriff
Pour suivre chaque demande de vérification et faire le lien avec les webhooks reçus, j'ai créé l'entité VeriffSession associée à la table SQL VERIFF_SESSION.
<?php
declare(strict_types=1);
namespace App\Domain\Entity;
use App\Domain\Entity\Trait\IdTrait;
use App\Domain\Entity\Trait\TimeStampableTrait;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Table(name: 'VERIFF_SESSION')]
#[ORM\Entity]
#[ORM\HasLifecycleCallbacks]
class VeriffSession
{
use TimeStampableTrait;
use IdTrait;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(name: 'USER_ID', referencedColumnName: 'ID', nullable: false)]
private User $user;
#[ORM\Column(name: 'STATUS', type: Types::STRING, length: 255, nullable: true)]
private string $status;
#[ORM\Column(name: 'VERIFICATION_URL', type: Types::TEXT, nullable: true)]
private ?string $verificationUrl = null;
#[ORM\Column(name: 'SESSION_ID', type: Types::GUID, nullable: true)]
private ?string $sessionId = null;
#[ORM\Column(name: 'DATA_SEND', type: Types::JSON, nullable: true)]
private ?array $dataSend;
#[ORM\Column(name: 'DATA_CALLBACK', type: Types::JSON, nullable: true)]
private ?array $dataCallback;
// Getters et Setters...
}
Le service VeriffClient s'occupe d'ouvrir la session auprès de l'API Veriff. Il récupère l'URL de redirection et passe l'identifiant de notre utilisateur (endUserId) dans le champ vendorData.
<?php
declare(strict_types=1);
namespace App\Infrastructure\Lib\IdentityVerifications\Veriff;
use App\Application\ServerParams;
use App\Domain\Entity\User;
use App\Domain\Entity\VeriffSession;
use App\Domain\Enum\UserIdentityVerification;
use App\Domain\Enum\VeriffStatus;
use App\Domain\Manager\EntityManagerInterface;
use App\Infrastructure\Manager\SiteManager;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class VeriffClient
{
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly RouterInterface $router,
private readonly SiteManager $siteManager,
private readonly EntityManagerInterface $em,
private readonly ServerParams $serverParams,
) {
}
public function getSession(User $user): ?VeriffSession
{
$session = new VeriffSession();
[$body, $response] = $this->createSession($user);
if (!$response) {
return null;
}
$session->setUser($user);
$session->setDataSend($body);
$session->setDataCallback([0 => $response]);
if (VeriffStatus::SUCCESS === $response['status']) {
$session->setStatus(VeriffStatus::SUCCESS);
$session->setSessionId($response['verification']['id']);
$session->setVerificationUrl($response['verification']['url']);
}
$user->setIsIdentityVerified(UserIdentityVerification::IN_PROGRESS);
$this->em->persist($session);
$this->em->flush();
return $session;
}
public function createSession(User $user): array
{
$data = [
'verification' => [
'callback' => $this->siteManager->getFullyQualifiedDomain(null, null) . $this->router->generate('myspace_myprofile_trust'),
'vendorData' => \json_encode([
'endUserId' => $user->getId(),
]),
],
];
$request = $this->httpClient->request('POST', $this->serverParams->getVeriffBaseUrl() . '/v1/sessions', [
'headers' => [
'accept' => 'application/json',
'x-auth-client' => $this->serverParams->getVeriffApiKey(),
'content-type' => 'application/json',
],
'body' => \json_encode($data),
]);
$response = \json_decode($request->getContent(false), true);
return [$data, $response];
}
}
5. Sécurisation du Webhook (Signature HMAC SHA-256 & API Key)
Sur l'endpoint de webhook (/api/external/veriff/webhook/{type}), il faut s'assurer que chaque requête provient bien de Veriff et n'a pas été altérée.
Mécanisme de sécurité : Le contrôle repose sur deux éléments. L'en-tête x-auth-client doit correspondre à notre clé API. De plus, la signature HMAC SHA-256 calculée sur le corps brut de la requête ($request->getContent()) avec notre secret VERIFF_SIGNATURE_KEY doit être identique à l'en-tête X-Hmac-Signature transmis par Veriff.
<?php
declare(strict_types=1);
namespace App\Infrastructure\Lib\IdentityVerifications\Veriff;
use App\Application\ServerParams;
use App\Application\Task\User\UpdateUserKycTask;
use App\Application\Vo\TaskObject\UpdateUserKycTo;
use App\Domain\Enum\UserKycProvider;
use App\Domain\Enum\VeriffWebhookType;
use App\Domain\Utils\LogUtils;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Webmozart\Assert\Assert;
final class VeriffWebhook extends Api
{
// ...
private function isValidRequest(Request $request): bool
{
$veriffApiKey = $this->serverParams->getVeriffApiKey();
$xAuthClientHeader = $request->headers->get('x-auth-client');
if ($veriffApiKey !== $xAuthClientHeader) {
LogUtils::info('Veriff bad x-auth-client detected :', ['Request' => $request]);
return false;
}
$veriffSignatureKey = $this->serverParams->getVeriffSignatureKey();
$veriffSignature = \hash_hmac('sha256', $request->getContent(), $veriffSignatureKey);
$veriffSignature = \strtolower($veriffSignature);
$xHmacSignature = $request->headers->get('X-Hmac-Signature');
if ($veriffSignature !== $xHmacSignature) {
LogUtils::info('Veriff bad x-hmac-signature detected :', ['Request' => $request]);
return false;
}
return true;
}
}
6. Orchestration via la tâche UpdateUserKycTask et le DTO UpdateUserKycTo
Pour éviter d'alourdir le contrôleur de webhook avec de la logique métier, j'ai préféré utiliser le pattern Task / TaskObject (TO). La tâche UpdateUserKycTask regroupe toutes les actions métier : mise à jour du statut de l'utilisateur, envoi des notifications (e-mails, SMS) et déblocage des versements en attente.
<?php
declare(strict_types=1);
namespace App\Application\Task\User;
use App\Application\Task\SendSmsTask;
use App\Application\Task\TaskInterface;
use App\Application\Task\Transaction\ProcessWaitingPayoutTask;
use App\Application\Vo\TaskObject\TaskObjectInterface;
use App\Application\Vo\TaskObject\UpdateUserKycTo;
use App\Domain\Enum\EmailName;
use App\Domain\Enum\SmsType;
use App\Domain\Enum\UserIdentityVerification;
use App\Domain\Enum\UserKycProvider;
use App\Domain\Enum\VeriffStatus;
use App\Domain\Utils\LogUtils;
use App\Infrastructure\Builder\EmailBuilder;
use App\Infrastructure\Manager\EntityManager;
use App\Infrastructure\Repository\Doctrine\TransactionLtRepository;
use Webmozart\Assert\Assert;
class UpdateUserKycTask implements TaskInterface
{
public function __construct(
protected readonly EntityManager $em,
protected readonly SendSmsTask $sendSmsTask,
protected readonly EmailBuilder $emailBuilder,
protected readonly TransactionLtRepository $transactionLtRepository,
protected readonly ProcessWaitingPayoutTask $processWaitingPayoutTask,
) {
}
public function exec(TaskObjectInterface $to): void
{
if (!($to instanceof UpdateUserKycTo)) {
throw new \ErrorException('to not UpdateUserKycTo');
}
Assert::true(UserKycProvider::isValid($to->userKycProvider));
Assert::notNull($to->user, 'UpdateUserKycTask: User is null at exec time');
// Ne jamais réécrire un statut déjà validé
if (UserIdentityVerification::VERIFIED === $to->user->getIsIdentityVerified()) {
LogUtils::info('KYC status already validated for user :', ['user' => $to->user->getCode()]);
return;
}
match ($to->userKycProvider) {
UserKycProvider::INTERNAL => $this->updateInternalKyc($to),
UserKycProvider::VERIFF => $this->updateVeriffKyc($to),
default => throw new \ErrorException('unhandled UserKycProvider'),
};
$this->em->flush();
}
private function updateVeriffKyc(UpdateUserKycTo $to): void
{
Assert::notNull($to->veriffStatus, 'veriff Status null in updateVeriffKyc()');
Assert::true(VeriffStatus::isValid($to->veriffStatus), 'invalid veriff Status');
try {
match ($to->veriffStatus) {
VeriffStatus::APPROVED => $this->validateVeriffKyc($to),
VeriffStatus::DECLINED => $this->declineVeriffKyc($to),
VeriffStatus::RESUBMISSION_REQUESTED,
VeriffStatus::EXPIRED,
VeriffStatus::ABANDONED => $this->timeoutVeriffKyc($to),
default => throw new \ErrorException('unhandled Veriff decision status'),
};
} catch (\Exception $e) {
$this->declineVeriffKyc($to);
LogUtils::info($e->getMessage(), ['veriffStatus' => $to->veriffStatus]);
}
}
private function validateVeriffKyc(UpdateUserKycTo $to): void
{
if ($to->fname !== $to->user->getFname()) {
$to->user->setFname($to->fname);
}
if ($to->lname !== $to->user->getLname()) {
$to->user->setLname($to->lname);
}
$to->user->setIsIdentityVerified(UserIdentityVerification::VERIFIED);
$to->veriffSession->setStatus(VeriffStatus::APPROVED);
$this->emailBuilder->send($to->user, EmailName::MODERATION_USER_KYC_ACCEPTED);
$this->sendSms($to, SmsType::KYC_ACCEPTED);
// Déblocage des versements en attente
$this->processWaitingPayout($to);
}
private function processWaitingPayout(UpdateUserKycTo $to): void
{
$transactionsToLandlord = $this->transactionLtRepository->findWaitingTransactionsToLandlordsByLandlord($to->user);
foreach ($transactionsToLandlord as $transaction) {
try {
$this->processWaitingPayoutTask->withTransaction($transaction);
$this->processWaitingPayoutTask->exec();
} catch (\Exception $e) {
LogUtils::exception2($e, 'ERROR transferring transaction', ['tr' => $transaction->getId()]);
}
}
}
}
7. Récupération asynchrone de la photo de profil (Symfony Messenger)
Quand une vérification est acceptée (APPROVED), Veriff dispose d'une photo nette du visage. Si l'utilisateur n'a pas encore de photo de profil sur son compte, j'ai utilisé Symfony Messenger pour la télécharger en arrière-plan sans ralentir la réponse au webhook.
<?php
declare(strict_types=1);
namespace App\Application\Handler\User;
use App\Application\Message\User\SyncUserProfilePhotoFromKycProviderMessage;
use App\Application\Task\User\SyncUserProfilePhotoAfterExternalKycTask;
use App\Application\Vo\TaskObject\SyncUserProfilePhotoTo;
use App\Domain\Entity\User;
use App\Domain\Enum\UserKycProvider;
use App\Domain\Enum\VeriffStatus;
use App\Infrastructure\Lib\IdentityVerifications\Veriff\VeriffClient;
use App\Infrastructure\Repository\Doctrine\UserRepository;
use App\Infrastructure\Repository\Doctrine\VeriffSessionRepository;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final class SyncUserProfilePhotoFromKycProviderHandler
{
public function __construct(
protected readonly UserRepository $userRepository,
protected readonly VeriffSessionRepository $veriffSessionRepository,
protected readonly SyncUserProfilePhotoAfterExternalKycTask $syncUserProfilePhotoAfterExternalKycTask,
protected readonly VeriffClient $veriffClient,
) {
}
public function __invoke(SyncUserProfilePhotoFromKycProviderMessage $message): void
{
if (UserKycProvider::VERIFF === $message->getProvider()) {
$this->updatePhotoFromVeriff($message->getUser());
}
}
private function updatePhotoFromVeriff(User $user): void
{
$sessionId = $this->veriffSessionRepository->findSessionIdByUser($user->getId());
$medias = $this->veriffClient->getAttemptsMedias($sessionId);
$facePhotoId = '';
$extension = '';
foreach ($medias['images'] as $image) {
if ('face' === $image['context'] || (empty($facePhotoId) && 'face-pre' === $image['context'])) {
$facePhotoId = $image['id'];
$extension = $image['mimetype'];
}
}
$extension = \preg_replace('#.*/#', '', $extension);
$facePhoto = $this->veriffClient->getUserPhoto($facePhotoId);
$this->syncUserProfilePhotoAfterExternalKycTask->exec(new SyncUserProfilePhotoTo(
$user,
$facePhoto,
$extension,
));
}
}
8. Normalisation des noms et prénoms (formatDbFname & iconv)
L'OCR de Veriff relève les données d'identité inscrites sur les documents officiels. Les accents ou caractères spéciaux de certains passeports peuvent cependant poser problème en base SQL ou lors des transferts bancaires.
Dans mon cas, j'ai ajouté dans le UserManager les méthodes formatDbFname et formatDbLname. Elles utilisent iconv avec l'option ASCII//TRANSLIT pour nettoyer les caractères avant l'enregistrement.
<?php
declare(strict_types=1);
namespace App\Application\Manager;
final class UserManager
{
public static function formatDbFname(string $name, string $encoding = 'UTF-8'): string
{
$firstChar = \mb_substr($name, 0, 1, $encoding);
$rest = \mb_substr($name, 1, null, $encoding);
$restLower = \mb_strtolower($rest, $encoding);
$asciiFirst = \iconv($encoding, 'ASCII//TRANSLIT', $firstChar);
if (false === $asciiFirst || '' === $asciiFirst) {
$asciiFirst = '';
}
$firstUpper = \mb_strtoupper($asciiFirst, $encoding);
return self::cleanName($firstUpper . $restLower);
}
public static function formatDbLname(string $name, string $encoding = 'UTF-8'): string
{
$name = \iconv($encoding, 'ASCII//TRANSLIT', $name);
return $name;
}
}
Ce tableau résume le comportement du nettoyage :
| Valeur OCR extraite | Résultat après formatage | Raison & Transformation |
|---|---|---|
| ÉLOÏSE | Eloise | Majuscule translittérée (É -> E) et remise en minuscule du reste |
| MÜLLER | Muller | Suppression des umlauts via ASCII//TRANSLIT |
| JEAN-FRANÇOIS | Jean-Francois | Maintien du tiret composé et nettoyage de la cédille |
9. Gestion des cas d'erreur, des doublons et des webhooks tardifs
Dans un fonctionnement événementiel avec des webhooks HTTP, il faut anticiper plusieurs cas particuliers :
- Webhooks reçus dans le mauvais ordre : Si un webhook d'abandon (ABANDONED) ou d'expiration (EXPIRED) arrive après une validation (APPROVED), l'utilisateur ne doit pas perdre son statut vérifié. La vérification dans UpdateUserKycTask bloque la modification si isIdentityVerified === 'VERIFIED'.
- Sessions expirées : Si l'utilisateur n'a pas finalisé la démarche au bout de 7 jours, la session Veriff repasse en EXPIRED. Le contrôleur du profil autorise alors l'ouverture d'une nouvelle session.
- E-mails et SMS d'information : En cas d'échec (document périmé ou illisible), un e-mail et un SMS repartent automatiquement pour inviter l'utilisateur à recommencer avec un document valide.
10. Bilan d'intégration
L'externalisation et l'automatisation de la vérification d'identité via l'API Veriff apportent des bénéfices majeurs tant sur le plan du produit que sur l'ingénierie :
- Gains d'expérience utilisateur et réduction des abandons : Le passage d'une modération manuelle en back-office (délai de 12h à 48h) à une validation automatique instantanée (moins de 2 minutes) a éliminé le principal point de friction lors de la première réservation ou de la création d'annonce.
- Conformité RGPD et réduction de la surface d'attaque : Le stockage des pièces d'identité brutes (passeports, cartes d'identité, permis) est intégralement délégué au tiers certifié Veriff. Les serveurs de la plateforme ne conservent que le statut de vérification (VERIFIED) et les données identitaires extraites, limitant drastiquement la responsabilité et les risques en cas d'incident de sécurité.
- Résilience et découplage architectural : L'architecture orientée Task/DTO (UpdateUserKycTask), la vérification d'intégrité systématique des signatures HMAC SHA-256 et l'exécution asynchrone des tâches via Symfony Messenger garantissent une parfaite étanchéité face aux pannes réseau ou aux réceptions de webhooks désordonnés.