API
Encaisser avec Adion Pay depuis votre logiciel
Votre site, votre boutique ou votre logiciel de caisse peut faire payer ses clients par carte avec Adion Pay. Votre logiciel crée une session de paiement, envoie l'acheteur sur la page de paiement d'Adion, puis reçoit le résultat par webhook. L'argent arrive sur votre compte Pay, comme pour un lien de paiement.
La référence est sur adion-api.eu
Ce guide montre le chemin. Chaque route, ses champs et ses réponses sont décrits sur adion-api.eu, groupe Pay.
Plus court avec une bibliothèque
En PHP ou en Node.js, les bibliothèques officielles obtiennent le jeton, créent la session et vérifient les webhooks en quelques lignes. Ce guide montre aussi les appels bruts, pour tout autre langage.
Avant de commencer
Un compte Adion Pay ouvert et actif dans un de vos projets (voir Adion Pay).
Une application serveur dans Mon compte › Applications, limitée au projet du compte Pay, avec les scopes :
pay.session.writepour créer des sessions ;pay.payment.readpour relire sessions et paiements ;pay.refund.writeseulement si votre logiciel rembourse.
Voir Créer une application.
Une adresse https publique où votre logiciel recevra les webhooks.
Le parcours
- Votre logiciel obtient un jeton avec l'identifiant et le secret de l'application.
- Il crée une session : un montant, une description, votre référence de commande, vos adresses de retour.
- Il envoie l'acheteur sur l'adresse
urlde la session (https://pay.adioncloud.com/…). - L'acheteur paie. La page le renvoie sur votre
success_url, avec?session_id=pay_sess_…. - Adion envoie le webhook
payment.succeededà votre adresse. C'est lui qui fait foi : le retour de l'acheteur sur votre site ne prouve rien.
Pour garder l'acheteur sur votre page plutôt que de le rediriger, voir le formulaire intégré.
1. Obtenir un jeton
curl -X POST https://adion-api.eu/api/auth/token \
-d grant_type=client_credentials \
-d client_id=VOTRE_CLIENT_ID \
-d client_secret=VOTRE_SECRETLa réponse donne access_token, valable une heure. Gardez-le en mémoire et redemandez-en un quand il expire.
2. Créer une session
curl -X POST https://adion-api.eu/api/v1/pay/sessions \
-H "Authorization: Bearer $JETON" \
-H "Idempotency-Key: commande-4521" \
-H "Content-Type: application/json" \
-d '{
"amount_cents": 4990,
"description": "Commande n° 4521",
"reference": "CMD-4521",
"metadata": {"order_id": "4521"},
"customer_email": "client@exemple.fr",
"success_url": "https://boutique.exemple.fr/commande/4521/merci",
"cancel_url": "https://boutique.exemple.fr/panier"
}'amount_cents: le montant TTC en centimes (4990 = 49,90 €), en euros.referenceetmetadata: les vôtres. Ils reviennent avec le paiement et dans chaque webhook.expires_at(facultatif) : la session expire au bout de 24 h par défaut, de 30 minutes à 7 jours.pay_account_id: seulement si plusieurs comptes Pay encaissent dans vos projets.
La réponse (201) donne id (pay_sess_…), url, status (open) et expires_at. Envoyez l'acheteur sur url.
L'en-tête Idempotency-Key
Donnez une clé par commande. Si le réseau coupe et que votre logiciel réessaie, la même session revient (200) au lieu d'une seconde. La même clé avec un autre contenu est refusée (409).
En PHP (Laravel)
use Illuminate\Support\Facades\Http;
$token = Http::asForm()->post('https://adion-api.eu/api/auth/token', [
'grant_type' => 'client_credentials',
'client_id' => config('services.adion_pay.client_id'),
'client_secret' => config('services.adion_pay.client_secret'),
])->throw()->json('access_token');
$session = Http::withToken($token)
->withHeaders(['Idempotency-Key' => 'commande-'.$order->id])
->post('https://adion-api.eu/api/v1/pay/sessions', [
'amount_cents' => $order->total_cents,
'description' => 'Commande n° '.$order->id,
'reference' => (string) $order->id,
'customer_email' => $order->email,
'success_url' => route('commande.merci', $order),
'cancel_url' => route('panier'),
])->throw()->json();
$order->update(['adion_session_id' => $session['id']]);
return redirect()->away($session['url']);En JavaScript (Node.js)
const tokenResponse = await fetch('https://adion-api.eu/api/auth/token', {
method: 'POST',
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: process.env.ADION_CLIENT_ID,
client_secret: process.env.ADION_CLIENT_SECRET,
}),
})
const { access_token } = await tokenResponse.json()
const response = await fetch('https://adion-api.eu/api/v1/pay/sessions', {
method: 'POST',
headers: {
Authorization: `Bearer ${access_token}`,
'Content-Type': 'application/json',
'Idempotency-Key': `commande-${order.id}`,
},
body: JSON.stringify({
amount_cents: order.totalCents,
description: `Commande n° ${order.id}`,
reference: String(order.id),
success_url: `https://boutique.exemple.fr/commande/${order.id}/merci`,
}),
})
const session = await response.json()
// Rediriger l'acheteur vers session.urlNe créez jamais une session depuis le navigateur : le secret de l'application ne quitte pas votre serveur.
3. Le retour de l'acheteur
Payé, l'acheteur revient sur success_url?session_id=pay_sess_…. Si votre adresse contient {SESSION_ID}, l'identifiant est mis à sa place. S'il renonce, la page lui propose de revenir sur cancel_url.
Pour afficher le bon message, relisez la session : GET /api/v1/pay/sessions/{id}. status vaut open (pas encore payée), complete (payée : payment_id) ou expired.
4. Recevoir les webhooks
Les adresses de webhook d'un compte Pay se déclarent par l'API (POST /api/account/pay/{pay_account_id}/webhook, droit Gérer les webhooks Pay, scope pay.webhook.manage) ; l'écran de l'espace client est en préparation. Choisissez les événements :
| Événement | Quand |
|---|---|
payment.succeeded | Un paiement a abouti |
payment.failed | Un paiement a été refusé |
refund.succeeded | Un remboursement est effectué |
dispute.opened | L'acheteur conteste un paiement auprès de sa banque (dispute.status : open, avec la date limite des justificatifs) |
dispute.closed | La contestation est close (dispute.status : won, lost, ou closed pour une simple demande d'information de la banque) |
session.expired | Une session n'a pas été payée à temps |
Le secret de signature (whsec_…) s'affiche une seule fois, à la création. Rangez-le avec le secret de l'application.
Chaque envoi est un POST en JSON :
{
"id": "pay_evt_AbCdEf123456",
"object": "event",
"type": "payment.succeeded",
"api_version": "2026-10-14",
"created": "2026-10-14T10:03:12+00:00",
"livemode": false,
"pay_account_id": "pay_acct_AbCdEf123456",
"data": {
"object": {
"id": "pay_pmt_AbCdEf123456",
"object": "pay.payment",
"status": "succeeded",
"amount_cents": 4990,
"reference": "CMD-4521",
"metadata": {"order_id": "4521"},
"session_id": "pay_sess_AbCdEf123456"
}
}
}Vérifier la signature
L'en-tête Adion-Signature ressemble à t=1760436192,v1=5257a8…. t est l'heure d'envoi, v1 le HMAC-SHA256, en hexadécimal, de t + . + le corps brut, avec votre secret.
- Lisez le corps brut, avant tout décodage JSON.
- Recalculez le HMAC et comparez-le en temps constant.
- Refusez un
tde plus de cinq minutes. - Répondez 2xx vite ; faites le travail long ensuite.
// Laravel : routes/web.php, route exclue de la protection CSRF
Route::post('/webhooks/adion-pay', function (Illuminate\Http\Request $request) {
$body = $request->getContent();
parse_str(str_replace(',', '&', (string) $request->header('Adion-Signature')), $sig);
$expected = hash_hmac('sha256', ($sig['t'] ?? '').'.'.$body, config('services.adion_pay.webhook_secret'));
abort_unless(isset($sig['v1']) && hash_equals($expected, $sig['v1']) && abs(time() - (int) $sig['t']) <= 300, 400);
$event = json_decode($body, true);
if ($event['type'] === 'payment.succeeded') {
Order::where('id', $event['data']['object']['reference'])->update(['paid' => true]);
}
return response()->noContent();
});// Node.js (Express) : le corps brut est indispensable
import crypto from 'node:crypto'
app.post('/webhooks/adion-pay', express.raw({ type: 'application/json' }), (req, res) => {
const parts = Object.fromEntries(String(req.get('Adion-Signature')).split(',').map((p) => p.split('=')))
const expected = crypto.createHmac('sha256', process.env.ADION_WEBHOOK_SECRET).update(`${parts.t}.${req.body}`).digest('hex')
const ok = parts.v1 && expected.length === parts.v1.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
if (!ok || Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return res.sendStatus(400)
const event = JSON.parse(req.body)
// event.type, event.data.object.reference…
res.sendStatus(204)
})Nouvelles tentatives
Sans réponse 2xx en dix secondes, Adion réessaie : après 1 minute, 5 minutes, 30 minutes, 2 heures, 5 heures, 10 heures, puis toutes les 12 heures, pendant trois jours. Un même événement peut donc arriver deux fois : servez-vous de son id pour ne le traiter qu'une fois.
Le journal des livraisons (code rendu, durée, début de la réponse) se lit par l'API, et une livraison se renvoie à la main (…/delivery/{id}/resend).
L'adresse doit être publique : une adresse interne (localhost, réseau privé) est refusée.
Formulaire intégré
Au lieu d'envoyer l'acheteur sur la page d'Adion, vous pouvez afficher le paiement dans votre propre page. Le formulaire vit dans une iframe servie par pay.adioncloud.com : les données de carte ne passent jamais par votre page ni par votre serveur.
1. Déclarer vos pages
Dans l'espace client, ouvrez le compte Pay, onglet Intégration, et ajoutez l'origine de chaque site qui affichera le paiement : https://boutique.exemple.fr (le protocole, le nom, un port s'il y en a un, rien après). Dix au plus. Un compte d'essai admet aussi http://localhost:<port>.
Le formulaire ne s'affiche que sur ces origines : le navigateur refuse de l'afficher dans toute autre page, et Adion refuse d'ouvrir la session. Sans origine déclarée, l'API refuse de créer une session intégrée (422).
2. Créer la session sur votre serveur
La même route, avec ui_mode à embedded. return_url (facultative) remplace success_url et cancel_url, qui sont refusées dans ce mode.
curl -X POST https://adion-api.eu/api/v1/pay/sessions \
-H "Authorization: Bearer $JETON" \
-H "Idempotency-Key: commande-4521" \
-H "Content-Type: application/json" \
-d '{
"amount_cents": 4990,
"description": "Commande n° 4521",
"reference": "CMD-4521",
"ui_mode": "embedded",
"return_url": "https://boutique.exemple.fr/commande/4521/merci"
}'La réponse n'a pas d'url, mais un client_secret propre à la session. Votre serveur le passe à la page de paiement. Il ne sert qu'à afficher et payer cette session, expire avec elle, n'ouvre aucun autre accès, et ne figure jamais dans les webhooks. Le secret de votre application, lui, ne quitte jamais votre serveur.
3. Afficher le formulaire
Une page complète. Le serveur de la boutique répond à POST /paiement/session avec {"clientSecret": "…"}, créé comme ci-dessus.
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Paiement de la commande 4521</title>
<script src="https://pay.adioncloud.com/v1/adion-pay.js"></script>
</head>
<body>
<h1>Commande n° 4521</h1>
<div id="paiement"></div>
<p id="message" hidden></p>
<script type="module">
// 1. Votre serveur crée la session intégrée et rend son client_secret.
const reponse = await fetch('/paiement/session', { method: 'POST' })
const { clientSecret } = await reponse.json()
// 2. Le formulaire d'Adion s'affiche dans la page.
const pay = AdionPay()
const checkout = await pay.initEmbeddedCheckout({
clientSecret,
appearance: { theme: 'auto' }, // 'light', 'dark' ou 'auto' (celui de l'acheteur)
onComplete: ({ sessionId }) => {
// L'acheteur a payé : remerciez-le. La commande se valide sur le webhook, pas ici.
checkout.destroy()
document.getElementById('message').hidden = false
document.getElementById('message').textContent = 'Merci ! Votre paiement est enregistré.'
},
})
checkout.on('error', ({ message }) => console.warn('Adion Pay :', message))
checkout.mount('#paiement')
</script>
</body>
</html>initEmbeddedCheckout()rend le formulaire ;mount()le place dans l'élément donné (un sélecteur ou un élément) ;destroy()le retire.- La hauteur de l'iframe suit celle du formulaire, toute seule.
- Événements :
ready(le formulaire est affiché),complete(payé),error(session introuvable, expirée, page non déclarée…), par les optionsonReady,onComplete,onErrorou parcheckout.on(…). - Sans
onComplete, l'acheteur part une fois payé surreturn_url, avec?session_id=pay_sess_…(ou à la place de{SESSION_ID}). - Le script est versionné :
/v1/ne changera pas de façon incompatible.
onComplete ne prouve rien
Comme le retour sur success_url, onComplete se passe dans le navigateur de l'acheteur : n'importe qui peut l'imiter. Le paiement fait foi par le webhook payment.succeeded (ou en relisant la session : status = complete). Ne livrez qu'à ce moment-là.
Rembourser
POST /api/v1/pay/payments/{id}/refunds, avec amount_cents pour un remboursement partiel, sans pour tout rembourser. Il faut le scope pay.refund.write. Le montant est repris sur votre compte Pay ; la commission n'est pas rendue. L'événement refund.succeeded suit.
Un paiement contesté ne se rembourse plus (409) : c'est la banque de l'acheteur qui décide de l'issue.
Les contestations
Quand un acheteur conteste un paiement auprès de sa banque, le paiement porte disputed: true et un objet dispute :
| Champ | Ce qu'il dit |
|---|---|
status | open tant que la banque n'a pas tranché, puis won, lost, ou closed (simple demande d'information, rien n'est débité) |
amount_cents | Le montant contesté |
evidence_due_at | La date limite pour fournir vos justificatifs |
debited_cents | Ce qui a été débité de votre compte Pay à l'ouverture de la contestation |
recredited_cents | Ce qui vous a été rendu, si la contestation est gagnée |
Le montant contesté est débité de votre compte Pay dès l'ouverture (conditions particulières, article 12.2) et recrédité si la banque vous donne raison. net_cents en tient compte.
Un agent IA ne rembourse pas
Un assistant branché par MCP peut créer une session de paiement, pas rembourser : c'est un geste d'argent, fait par vous ou par votre logiciel.
Tester
Un compte Pay en mode essai donne des sessions avec livemode: false. La page de paiement l'annonce et accepte la carte d'essai indiquée à l'écran. Aucun montant réel n'est débité.