Apparence
Brancher un paiement
Le problème
Entre le moment où l'utilisateur choisit sa place et celui où il paie, il se passe du temps — parfois plusieurs minutes, avec une redirection vers un prestataire de paiement. Il faut garantir la place pendant ce laps, ne pas la vendre deux fois, et ne pas la garder immobilisée si le paiement échoue.
Le flux, vu de haut
Navigateur Votre serveur Mitoera
│ │ │
│ 1. ouvre la page │ │
│──────────────────────────▶│ sessions.create() │
│ │───────────────────────▶│
│◀── sessionToken ──────────│◀── session ────────────│
│ │ │
│ 2. sélectionne des sièges │ │
│═════════════ hold automatique ════════════════════▶│
│ │ │
│ 3. onCheckout(seats) │ │
│──────────────────────────▶│ crée la commande │
│◀── URL de paiement ───────│ │
│ │ │
│ 4. paie chez le PSP │ │
│ │◀── webhook paiement OK │
│ │ holds.book() ─────────▶│
│ │ │
│ 5. page de confirmation │ │Le point clé : c'est le webhook du prestataire qui déclenche book(), pas le retour navigateur. Un utilisateur qui ferme son onglet après avoir payé doit quand même obtenir ses places.
Étape 1 — Ouvrir la session
ts
app.get('/billetterie/:slug', async (req, res) => {
const event = await client.events.findByIdentifier(req.params.slug);
const session = await client.sessions.create(event.id);
req.session.mitoera = {
eventId: event.id,
holdToken: session.holdToken, // reste côté serveur
};
res.render('billetterie', {
eventSlug: req.params.slug,
sessionToken: session.sessionToken, // seul jeton exposé au navigateur
});
});php
#[Route('/billetterie/{slug}', methods: ['GET'])]
public function page(string $slug, Request $request): Response
{
$event = $this->mitoera->events->findByIdentifier($slug);
$session = $this->mitoera->sessions->create($event->id);
$request->getSession()->set('mitoera', [
'eventId' => $event->id,
'holdToken' => $session->holdToken, // reste côté serveur
]);
return $this->render('billetterie.html.twig', [
'eventSlug' => $slug,
'sessionToken' => $session->sessionToken,
]);
}Étape 2 — Récupérer la sélection au checkout
Côté page, onCheckout envoie les sièges à votre serveur :
js
onCheckout: async (seats) => {
const res = await fetch('/api/commande', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ seatKeys: seats.map(s => s.seatKey) }),
});
const { paymentUrl } = await res.json();
window.location.href = paymentUrl;
},Côté serveur, on crée la commande et on part chez le PSP :
ts
app.post('/api/commande', async (req, res) => {
const { eventId, holdToken } = req.session.mitoera;
const seatKeys = req.body.seatKeys;
// Les sièges sont déjà en hold : le widget l'a fait à la sélection.
const commande = await db.commandes.create({
eventId, seatKeys, montant: calculerMontant(seatKeys), statut: 'en_attente',
});
const paiement = await psp.createCheckout({
montant: commande.montant,
metadata: { commandeId: commande.id },
});
res.json({ paymentUrl: paiement.url });
});php
#[Route('/api/commande', methods: ['POST'])]
public function commande(Request $request): JsonResponse
{
['eventId' => $eventId, 'holdToken' => $holdToken] = $request->getSession()->get('mitoera');
$seatKeys = $request->toArray()['seatKeys'];
// Les sièges sont déjà en hold : le widget l'a fait à la sélection.
$commande = $this->commandes->creer($eventId, $seatKeys, $this->calculerMontant($seatKeys));
$paiement = $this->psp->createCheckout(
montant: $commande->montant,
metadata: ['commandeId' => $commande->id],
);
return $this->json(['paymentUrl' => $paiement->url]);
}Prolongez le hold si le tunnel est long
La durée de hold est configurée dans le back-office. Si votre parcours de paiement dépasse cette durée, appelez sessions.refresh(sessionToken) avant la redirection, ou reposez un hold explicite avec holds.hold().
Étape 3 — Confirmer au webhook
ts
app.post('/webhook/paiement', async (req, res) => {
const evenement = psp.verifierSignature(req); // toujours vérifier
if (evenement.type !== 'paiement.reussi') return res.sendStatus(200);
const commande = await db.commandes.get(evenement.metadata.commandeId);
if (commande.statut === 'confirmee') return res.sendStatus(200); // idempotence
try {
await client.holds.book(commande.eventId, commande.seatKeys, commande.holdToken);
await db.commandes.update(commande.id, { statut: 'confirmee' });
} catch (e) {
if (e instanceof ApiException && e.statusCode === 409) {
// Place perdue malgré le hold (expiration, intervention manuelle)
await psp.rembourser(evenement.paiementId);
await db.commandes.update(commande.id, { statut: 'rembourse' });
await notifierClient(commande);
} else {
throw e;
}
}
res.sendStatus(200);
});php
#[Route('/webhook/paiement', methods: ['POST'])]
public function webhook(Request $request): Response
{
$evenement = $this->psp->verifierSignature($request); // toujours vérifier
if ($evenement->type !== 'paiement.reussi') {
return new Response('', 200);
}
$commande = $this->commandes->get($evenement->metadata['commandeId']);
if ($commande->statut === 'confirmee') {
return new Response('', 200); // idempotence
}
try {
$this->mitoera->holds->book($commande->eventId, $commande->seatKeys, $commande->holdToken);
$this->commandes->confirmer($commande->id);
} catch (ApiException $e) {
if ($e->statusCode !== 409) {
throw $e;
}
// Place perdue malgré le hold (expiration, intervention manuelle)
$this->psp->rembourser($evenement->paiementId);
$this->commandes->rembourser($commande->id);
$this->notifierClient($commande);
}
return new Response('', 200);
}Étape 4 — Libérer en cas d'abandon
Trois cas d'abandon à couvrir :
| Cas | Détection | Action |
|---|---|---|
| L'utilisateur revient sans payer | Retour sur l'URL cancel_url du PSP | holds.release() |
| Le paiement échoue | Webhook paiement.echoue | holds.release() |
| L'utilisateur disparaît | Rien — le hold expire tout seul | Aucune, mais une tâche de ménage sur vos commandes en_attente fait du bien |
ts
app.get('/paiement/annule', async (req, res) => {
const { eventId, holdToken } = req.session.mitoera;
const commande = await db.commandes.dernierEnAttente(req.session.id);
if (commande) {
await client.holds.release(eventId, commande.seatKeys, holdToken);
await db.commandes.update(commande.id, { statut: 'annulee' });
}
res.redirect(`/billetterie/${commande.slug}`);
});php
#[Route('/paiement/annule', methods: ['GET'])]
public function annule(Request $request): Response
{
['eventId' => $eventId, 'holdToken' => $holdToken] = $request->getSession()->get('mitoera');
$commande = $this->commandes->dernierEnAttente($request->getSession()->getId());
if ($commande) {
$this->mitoera->holds->release($eventId, $commande->seatKeys, $holdToken);
$this->commandes->annuler($commande->id);
}
return $this->redirectToRoute('billetterie', ['slug' => $commande->slug]);
}Les cinq règles à respecter
- Confirmez au webhook, pas au retour navigateur. Le retour peut ne jamais arriver.
- Rendez le webhook idempotent. Les PSP rejouent leurs notifications ;
book()deux fois sur la même commande doit être sans effet. - Traitez le
409comme un cas prévu. Un hold peut expirer pendant un paiement long : prévoyez le remboursement automatique. - Gardez le
holdTokencôté serveur. Il ne doit jamais transiter par le navigateur. - Libérez explicitement à l'abandon. Attendre l'expiration immobilise des places vendables.
Voir aussi
- Holds et sessions — durée de vie et propriété des jetons
- Réservations (API) — les endpoints bruts
- Codes d'erreur — stratégie de réessai

