Bien démarrer
Tester votre intégration
Chaque parcours possible de vos clients, chaque échec et le traitement de vos webhooks, testés de bout en bout dans le bac à sable avant la production.
Sur cette page
Le mode test exécute le même code que la production : la même validation, les mêmes statuts, les mêmes événements et les mêmes webhooks (voir Bac à sable et production : les différences). Ce qui marche ici marche en production. Testez d’abord le parcours de vos clients, puis chaque façon dont un paiement peut échouer, puis ce que votre serveur fait des webhooks qu’il reçoit.
Avant de commencer
- Une clé secrète de test dans
APX_SECRET_KEY: voir Vos clés d’API. - Un endpoint de webhook en mode test, avec son secret dans
APX_WEBHOOK_SECRET. Pour recevoir les événements sur votre propre ordinateur, exposez votre serveur avec un tunnel HTTPS : voir le démarrage rapide carte. - Le tableau de bord, en mode test, pour suivre ce qui se passe :
- Développeurs → Journaux de requêtes liste chaque appel à l’API avec son statut et son
Request-Id; - Développeurs → Événements liste chaque événement, avec ses livraisons à chaque endpoint ;
- la page d’un endpoint, sous Développeurs → Webhooks, montre chaque livraison et vos réponses.
- Développeurs → Journaux de requêtes liste chaque appel à l’API avec son statut et son
Paiements par carte
Payez chaque scénario sur la page hébergée avec la carte de test indiquée, ou sans navigateur avec les assistants de test.
Honore la commande une seule fois, sur payment_intent.succeeded.
Indique que le paiement a échoué et laisse votre client réessayer. La commande reste ouverte.
une fois avec Approuver et une fois avec Échouer
Approuvé : comme un succès. Échoué : comme un refus, avec authentication_failed.
Votre client revient sur success_url avant la confirmation du paiement : affichez « Nous confirmons votre paiement », et livrez sur le webhook, environ 60 secondes plus tard.
Une seule exécution : un e-mail, un mouvement de stock.
Ne livre jamais : le paiement ne réussit pas, et sa tentative expire au bout d’environ 30 minutes.
Paiements en espèces
Agissez sur chaque code de test avec les boutons de la page du bon, ou avec les assistants de test. Ce que fait chaque issue est dans Scénarios de test en espèces.
Simuler le paiement, ou pay
Honore la commande sur payment_intent.succeeded.
expire
Libère la commande ou le stock, et laisse votre client obtenir un nouveau code.
pay avec un autre amount
Ne livre rien : le paiement reçoit une review et rien n’est crédité. Elle attend.
expire, puis pay
De même : une review, rien de crédité, rien de livré.
Un paiement en espèces de 5000
Traite le refus, amount_too_small : ne proposez que la carte pour les petits montants.
Remboursements
Remboursez une partie d’un paiement par carte réussi
Enregistre ce qui a été remboursé ; l’amount_refunded de l’intention de paiement augmente.
Remboursez le reste
Marque la commande remboursée quand le remboursement réussit.
Essayez de rembourser un paiement en espèces
Traite le refus, refund_unsupported_payment_method : votre propre procédure pour les espèces prend le relais. Voir Remboursements.
Webhooks
Envoyez à votre gestionnaire une livraison au corps modifié (Tester votre gestionnaire)
Répond 400 et ne fait rien d’autre.
Renvoyez une livraison depuis le tableau de bord
Le traite une seule fois : l’id de l’événement est déjà connu. Voir la déduplication.
Arrêtez votre serveur, payez, redémarrez-le, puis Renvoyer ou attendez la nouvelle tentative
Honore la commande quand la livraison passe.
Requêtes
Envoyez une création deux fois avec la même Idempotency-Key
Un seul paiement ; la deuxième réponse porte Idempotent-Replayed: true. Voir Idempotence.
Dans vos propres tests, répondez 429 à votre client
Attend le délai Retry-After, puis réessaie avec la même clé. Voir Limites de débit.
Une clé de test à laquelle il manque un caractère
Traite 401, api_key_invalid : une alerte pour vous, jamais une nouvelle tentative.
Un test automatisé : un code espèces qui expire
Votre propre suite de tests peut exécuter chacun de ces scénarios contre le bac à sable. Celui-ci
crée un paiement en espèces, le confirme avec le nom et la ville de votre client, fait expirer son
code avec l’assistant de test, puis attend que le paiement revienne en requires_payment_method avec
voucher_expired.
Chaque création et chaque confirmation porte sa propre Idempotency-Key, renouvelée à chaque
exécution. La réponse de la confirmation nomme la tentative dans latest_charge : l’assistant agit
sur elle.
API=https://pay-api.adpromax.com/v1
RUN=$(date +%s)
PI=$(curl -s $API/payment_intents -u "$APX_SECRET_KEY:" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: test-$RUN-create" \
-d '{"amount": 15000, "currency": "mad",
"payment_method_types": ["cash"]}' | jq -r .id)
CH=$(curl -s $API/payment_intents/$PI/confirm -u "$APX_SECRET_KEY:" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: test-$RUN-confirm" \
-d '{"payment_method_type": "cash",
"payer": {"name": "Salma Idrissi", "city": "Casablanca"}}' \
| jq -r .latest_charge)
curl -s -X POST $API/test_helpers/cash_vouchers/$CH/expire \
-u "$APX_SECRET_KEY:" > /dev/null
for i in 1 2 3 4 5 6 7 8 9 10; do
INTENT=$(curl -s $API/payment_intents/$PI -u "$APX_SECRET_KEY:")
STATUS=$(echo "$INTENT" | jq -r .status)
[ "$STATUS" = requires_payment_method ] && break
sleep 1
done
echo "$PI $STATUS $(echo "$INTENT" | jq -r .last_payment_error.code)"import { randomUUID } from 'node:crypto'
const api = 'https://pay-api.adpromax.com/v1'
const auth = `Bearer ${process.env.APX_SECRET_KEY}`
const run = randomUUID()
async function call(method, path, body, key) {
const headers = { Authorization: auth }
if (body !== undefined)
headers['Content-Type'] = 'application/json'
if (key !== undefined)
headers['Idempotency-Key'] = `${run}-${key}`
const response = await fetch(`${api}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
})
const answer = await response.json()
if (!response.ok)
throw new Error(answer.error.message)
return answer
}
const intent = await call('POST', '/payment_intents', {
amount: 15000,
currency: 'mad',
payment_method_types: ['cash'],
}, 'create')
const confirmed = await call('POST', `/payment_intents/${intent.id}/confirm`, {
payment_method_type: 'cash',
payer: { name: 'Salma Idrissi', city: 'Casablanca' },
}, 'confirm')
const charge = confirmed.latest_charge
await call('POST', `/test_helpers/cash_vouchers/${charge}/expire`)
let latest
for (let i = 0; i < 10; i++) {
await new Promise(resolve => setTimeout(resolve, 1000))
latest = await call('GET', `/payment_intents/${intent.id}`)
if (latest.status === 'requires_payment_method')
break
}
// Expected: requires_payment_method voucher_expired
console.log(intent.id, latest.status, latest.last_payment_error?.code)import { randomUUID } from 'node:crypto'
interface PaymentIntent {
id: string
status: string
latest_charge: string | null
last_payment_error: { code: string, message: string } | null
}
interface ApiError {
error: { type: string, code: string, message: string, request_id: string }
}
const api = 'https://pay-api.adpromax.com/v1'
const auth = `Bearer ${process.env.APX_SECRET_KEY}`
const run = randomUUID()
async function call<T>(
method: string,
path: string,
body?: object,
key?: string,
): Promise<T> {
const headers: Record<string, string> = { Authorization: auth }
if (body !== undefined)
headers['Content-Type'] = 'application/json'
if (key !== undefined)
headers['Idempotency-Key'] = `${run}-${key}`
const response = await fetch(`${api}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
})
if (!response.ok) {
const { error } = (await response.json()) as ApiError
throw new Error(`${error.code}: ${error.message}`)
}
return (await response.json()) as T
}
const intent = await call<PaymentIntent>('POST', '/payment_intents', {
amount: 15000,
currency: 'mad',
payment_method_types: ['cash'],
}, 'create')
const confirm = `/payment_intents/${intent.id}/confirm`
const confirmed = await call<PaymentIntent>('POST', confirm, {
payment_method_type: 'cash',
payer: { name: 'Salma Idrissi', city: 'Casablanca' },
}, 'confirm')
const charge = confirmed.latest_charge
await call('POST', `/test_helpers/cash_vouchers/${charge}/expire`)
let latest = confirmed
for (let i = 0; i < 10; i++) {
await new Promise(resolve => setTimeout(resolve, 1000))
latest = await call<PaymentIntent>('GET', `/payment_intents/${intent.id}`)
if (latest.status === 'requires_payment_method')
break
}
// Expected: requires_payment_method voucher_expired
console.log(intent.id, latest.status, latest.last_payment_error?.code)import os
import time
import uuid
import requests
API = "https://pay-api.adpromax.com/v1"
AUTH = (os.environ["APX_SECRET_KEY"], "")
RUN = uuid.uuid4().hex
def call(method, path, body=None, key=None):
headers = {"Idempotency-Key": f"{RUN}-{key}"} if key else {}
response = requests.request(
method, f"{API}{path}", auth=AUTH, json=body, headers=headers
)
answer = response.json()
response.raise_for_status()
return answer
intent = call("POST", "/payment_intents", {
"amount": 15000,
"currency": "mad",
"payment_method_types": ["cash"],
}, "create")
confirmed = call("POST", f"/payment_intents/{intent['id']}/confirm", {
"payment_method_type": "cash",
"payer": {"name": "Salma Idrissi", "city": "Casablanca"},
}, "confirm")
charge = confirmed["latest_charge"]
call("POST", f"/test_helpers/cash_vouchers/{charge}/expire")
latest = None
for _ in range(10):
time.sleep(1)
latest = call("GET", f"/payment_intents/{intent['id']}")
if latest["status"] == "requires_payment_method":
break
# Expected: requires_payment_method voucher_expired
error = latest["last_payment_error"] or {}
print(intent["id"], latest["status"], error.get("code"))<?php
function call(
string $method,
string $path,
?array $body = null,
?string $key = null
): array {
$headers = ['Content-Type: application/json'];
if ($key !== null) {
$headers[] = "Idempotency-Key: {$key}";
}
$ch = curl_init('https://pay-api.adpromax.com/v1' . $path);
curl_setopt_array($ch, [
CURLOPT_USERPWD => getenv('APX_SECRET_KEY') . ':',
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$answer = json_decode(curl_exec($ch), true);
return $answer;
}
$run = bin2hex(random_bytes(8));
$intent = call('POST', '/payment_intents', [
'amount' => 15000,
'currency' => 'mad',
'payment_method_types' => ['cash'],
], "{$run}-create");
$confirmed = call('POST', "/payment_intents/{$intent['id']}/confirm", [
'payment_method_type' => 'cash',
'payer' => ['name' => 'Salma Idrissi', 'city' => 'Casablanca'],
], "{$run}-confirm");
$charge = $confirmed['latest_charge'];
call('POST', "/test_helpers/cash_vouchers/{$charge}/expire");
for ($i = 0; $i < 10; $i++) {
sleep(1);
$latest = call('GET', "/payment_intents/{$intent['id']}");
if ($latest['status'] === 'requires_payment_method') {
break;
}
}
// Expected: requires_payment_method voucher_expired
echo $intent['id'], ' ', $latest['status'], ' ',
$latest['last_payment_error']['code'] ?? '', PHP_EOL;require 'json'
require 'minitest/autorun'
require 'net/http'
require 'securerandom'
class CashExpiryTest < Minitest::Test
API = 'https://pay-api.adpromax.com/v1'.freeze
RUN = SecureRandom.uuid
def call(verb, path, body = nil, key = nil)
uri = URI("#{API}#{path}")
request = Net::HTTP.const_get(verb).new(uri)
request.basic_auth(ENV.fetch('APX_SECRET_KEY'), '')
request['Content-Type'] = 'application/json'
request['Idempotency-Key'] = "#{RUN}-#{key}" if key
request.body = JSON.generate(body) if body
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
http.request(request)
end
answer = JSON.parse(response.body)
raise answer.dig('error', 'message') unless response.is_a?(Net::HTTPSuccess)
answer
end
def test_an_expired_code_reopens_the_payment
intent = call(:Post, '/payment_intents', {
amount: 15000,
currency: 'mad',
payment_method_types: ['cash']
}, 'create')
confirmed = call(:Post, "/payment_intents/#{intent['id']}/confirm", {
payment_method_type: 'cash',
payer: { name: 'Salma Idrissi', city: 'Casablanca' }
}, 'confirm')
charge = confirmed['latest_charge']
call(:Post, "/test_helpers/cash_vouchers/#{charge}/expire")
latest = nil
10.times do
sleep 1
latest = call(:Get, "/payment_intents/#{intent['id']}")
break if latest['status'] == 'requires_payment_method'
end
assert_equal 'requires_payment_method', latest['status']
assert_equal 'voucher_expired', latest.dig('last_payment_error', 'code')
end
endpackage main
import (
"bytes"
"crypto/rand"
"encoding/json"
"net/http"
"os"
"testing"
"time"
)
const api = "https://pay-api.adpromax.com/v1"
// A fresh run, so that every run's Idempotency-Key is new.
var run = rand.Text()
type paymentError struct {
Code string `json:"code"`
}
type paymentIntent struct {
ID string `json:"id"`
Status string `json:"status"`
LatestCharge string `json:"latest_charge"`
LastPaymentError *paymentError `json:"last_payment_error"`
}
func call(
t *testing.T, method, path string, body any, key string,
) paymentIntent {
t.Helper()
var payload bytes.Buffer
if body != nil {
json.NewEncoder(&payload).Encode(body)
}
req, err := http.NewRequest(method, api+path, &payload)
if err != nil {
t.Fatal(err)
}
req.SetBasicAuth(os.Getenv("APX_SECRET_KEY"), "")
req.Header.Set("Content-Type", "application/json")
if key != "" {
req.Header.Set("Idempotency-Key", run+"-"+key)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode >= 400 {
t.Fatalf("%s %s: %s", method, path, res.Status)
}
var answer paymentIntent
json.NewDecoder(res.Body).Decode(&answer)
return answer
}
func TestAnExpiredCodeReopensThePayment(t *testing.T) {
intent := call(t, http.MethodPost, "/payment_intents", map[string]any{
"amount": 15000,
"currency": "mad",
"payment_method_types": []string{"cash"},
}, "create")
payer := map[string]string{"name": "Salma Idrissi", "city": "Casablanca"}
confirmed := call(t, http.MethodPost,
"/payment_intents/"+intent.ID+"/confirm", map[string]any{
"payment_method_type": "cash",
"payer": payer,
}, "confirm")
expire := "/test_helpers/cash_vouchers/" + confirmed.LatestCharge + "/expire"
call(t, http.MethodPost, expire, nil, "")
var latest paymentIntent
for i := 0; i < 10; i++ {
time.Sleep(time.Second)
latest = call(t, http.MethodGet, "/payment_intents/"+intent.ID, nil, "")
if latest.Status == "requires_payment_method" {
break
}
}
code := ""
if latest.LastPaymentError != nil {
code = latest.LastPaymentError.Code
}
if latest.Status != "requires_payment_method" || code != "voucher_expired" {
t.Fatalf("%s: %s %s", intent.ID, latest.Status, code)
}
}import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpRequest.BodyPublishers;
import java.net.http.HttpResponse;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class CashExpiryTest {
static final String API = "https://pay-api.adpromax.com/v1";
static final String RUN = UUID.randomUUID().toString();
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String method, String path, String body, String key)
throws Exception {
HttpRequest.Builder request = HttpRequest
.newBuilder(URI.create(API + path))
.header("Authorization", "Bearer " + System.getenv("APX_SECRET_KEY"))
.header("Content-Type", "application/json")
.method(method, body == null
? BodyPublishers.noBody() : BodyPublishers.ofString(body));
if (key != null) {
request.header("Idempotency-Key", RUN + "-" + key);
}
HttpResponse<String> response = HTTP.send(
request.build(), HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 400) {
throw new IllegalStateException(response.body());
}
return response.body();
}
// The JDK has no JSON parser: a small regex reads one text field, the
// first with that name after `after`.
static String field(String json, String after, String name) {
Matcher match = Pattern
.compile("\"" + name + "\"\\s*:\\s*\"([^\"]*)\"")
.matcher(json);
int from = Math.max(json.indexOf(after), 0);
return match.find(from) ? match.group(1) : "";
}
public static void main(String[] args) throws Exception {
String created = call("POST", "/payment_intents", """
{
"amount": 15000,
"currency": "mad",
"payment_method_types": ["cash"]
}
""", "create");
String intent = field(created, "", "id");
String confirm = "/payment_intents/" + intent + "/confirm";
String confirmed = call("POST", confirm, """
{
"payment_method_type": "cash",
"payer": { "name": "Salma Idrissi", "city": "Casablanca" }
}
""", "confirm");
String charge = field(confirmed, "", "latest_charge");
String expire = "/test_helpers/cash_vouchers/" + charge + "/expire";
call("POST", expire, null, null);
String status = "";
String code = "";
for (int i = 0; i < 10; i++) {
Thread.sleep(1000);
String latest = call("GET", "/payment_intents/" + intent, null, null);
status = field(latest, "", "status");
code = field(latest, "\"last_payment_error\"", "code");
if (status.equals("requires_payment_method")) {
break;
}
}
// Expected: requires_payment_method voucher_expired
boolean reopened = status.equals("requires_payment_method");
if (!reopened || !code.equals("voucher_expired")) {
throw new AssertionError(intent + " " + status + " " + code);
}
System.out.println(intent + " " + status + " " + code);
}
}using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
const string Api = "https://pay-api.adpromax.com/v1";
var run = Guid.NewGuid().ToString();
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("APX_SECRET_KEY"));
async Task<JsonElement> Call(
HttpMethod method, string path, object? body = null, string? key = null)
{
using var request = new HttpRequestMessage(method, Api + path);
if (body is not null)
request.Content = JsonContent.Create(body);
if (key is not null)
request.Headers.Add("Idempotency-Key", $"{run}-{key}");
using var response = await http.SendAsync(request);
var answer = await response.Content.ReadFromJsonAsync<JsonElement>();
if (!response.IsSuccessStatusCode)
throw new InvalidOperationException(
answer.GetProperty("error").GetProperty("message").GetString());
return answer;
}
var intent = await Call(HttpMethod.Post, "/payment_intents", new
{
amount = 15000,
currency = "mad",
payment_method_types = new[] { "cash" },
}, "create");
var id = intent.GetProperty("id").GetString();
var confirmed = await Call(HttpMethod.Post, $"/payment_intents/{id}/confirm", new
{
payment_method_type = "cash",
payer = new { name = "Salma Idrissi", city = "Casablanca" },
}, "confirm");
var charge = confirmed.GetProperty("latest_charge").GetString();
await Call(HttpMethod.Post, $"/test_helpers/cash_vouchers/{charge}/expire");
var latest = confirmed;
for (var i = 0; i < 10; i++)
{
await Task.Delay(1000);
latest = await Call(HttpMethod.Get, $"/payment_intents/{id}");
if (latest.GetProperty("status").GetString() == "requires_payment_method")
break;
}
var status = latest.GetProperty("status").GetString();
var error = latest.GetProperty("last_payment_error");
var code = error.ValueKind == JsonValueKind.Object
? error.GetProperty("code").GetString()
: null;
// Expected: requires_payment_method voucher_expired
if (status != "requires_payment_method" || code != "voucher_expired")
throw new InvalidOperationException($"{id} {status} {code}");
Console.WriteLine($"{id} {status} {code}");// Cargo.toml: reqwest = { version = "0.12", features = ["blocking", "json"] }
// serde_json = "1"
// uuid = { version = "1", features = ["v4"] }
// Run it with `cargo test`.
use std::{thread::sleep, time::Duration};
use reqwest::{blocking::Client, Method};
use serde_json::{json, Value};
const API: &str = "https://pay-api.adpromax.com/v1";
fn call(
method: Method,
path: &str,
body: Option<Value>,
key: Option<String>,
) -> Value {
let mut request = Client::new()
.request(method, format!("{API}{path}"))
.bearer_auth(std::env::var("APX_SECRET_KEY").unwrap());
if let Some(body) = body {
request = request.json(&body);
}
if let Some(key) = key {
request = request.header("Idempotency-Key", key);
}
let response = request.send().unwrap();
let ok = response.status().is_success();
let answer: Value = response.json().unwrap();
assert!(ok, "{}", answer["error"]["message"]);
answer
}
fn main() {}
#[test]
fn an_expired_code_reopens_the_payment() {
// A fresh run, so that every run's Idempotency-Key is new.
let run = uuid::Uuid::new_v4();
let intent = call(
Method::POST,
"/payment_intents",
Some(json!({
"amount": 15000,
"currency": "mad",
"payment_method_types": ["cash"]
})),
Some(format!("{run}-create")),
);
let id = intent["id"].as_str().unwrap();
let confirmed = call(
Method::POST,
&format!("/payment_intents/{id}/confirm"),
Some(json!({
"payment_method_type": "cash",
"payer": { "name": "Salma Idrissi", "city": "Casablanca" }
})),
Some(format!("{run}-confirm")),
);
let charge = confirmed["latest_charge"].as_str().unwrap();
let expire = format!("/test_helpers/cash_vouchers/{charge}/expire");
call(Method::POST, &expire, None, None);
let mut latest = confirmed.clone();
for _ in 0..10 {
sleep(Duration::from_secs(1));
latest = call(Method::GET, &format!("/payment_intents/{id}"), None, None);
if latest["status"] == "requires_payment_method" {
break;
}
}
assert_eq!(latest["status"], "requires_payment_method");
assert_eq!(latest["last_payment_error"]["code"], "voucher_expired");
}Ce que voient vos clients
Vos clients paient sur nos pages, le plus souvent sur un téléphone. Regardez-les comme eux :
- Ouvrez l’
urld’une session de paiement sur un téléphone, en français, en anglais et en arabe (la page suit la langue du navigateur). Payez par carte, puis demandez un code espèces. - Sur la page du bon, imprimez le bon et partagez-le : le code, le montant à payer et l’échéance doivent se lire clairement.
- Quittez la page pour votre boutique, puis rouvrez l’
urlde la session : un client parti peut revenir sur une session encore ouverte.
Laissez le tableau de bord vous aider
- La carte Intégrer de l’accueil se coche toute seule à mesure qu’arrivent vos paiements de test, vos webhooks et vos codes espèces.
- En mode test, Générer des paiements d’exemple sur l’accueil remplit les écrans de paiements et de clients d’exemple, pour voir ce que montre chaque page.
- Supprimer toutes les données de test, sous Paramètres → Organisation, vous fait repartir d’un bac à sable vide. Vos clés et votre équipe sont gardées.
Et ensuite
Quand chaque scénario de cette page passe, lisez la liste de contrôle avant la production.