Get started
Test your integration
Every path your customers can take, each failure, and your webhook handling, tested end to end in the sandbox before you go live.
On this page
Test mode runs the same code as live mode: the same validation, the same statuses, the same events and the same webhooks (see Sandbox and live: the differences). What works here works in live mode. Test the path your customers take first, then each way a payment can fail, then what your server does with the webhooks it receives.
Before you start
- A test secret key in
APX_SECRET_KEY: see Your API keys. - A webhook endpoint in test mode, with its secret in
APX_WEBHOOK_SECRET. To receive events on your own computer, expose your server with an HTTPS tunnel: see the card quickstart. - The dashboard, in test mode, to watch what happens:
- Developers → Request logs lists each API call with its status and its
Request-Id; - Developers → Events lists each event, with its deliveries to each endpoint;
- an endpoint's page under Developers → Webhooks shows every delivery and your answers.
- Developers → Request logs lists each API call with its status and its
Card payments
Pay each scenario on the hosted page with the test card named, or without a browser through the test helpers.
Fulfils the order once, on payment_intent.succeeded.
Shows that the payment failed and lets your customer try again. The order stays open.
once with Approve and once with Fail
Approved: as a success. Failed: as a decline, with authentication_failed.
Your customer is back on success_url before the payment is confirmed: show "We are confirming your payment", and deliver on the webhook, about 60 seconds later.
One fulfilment: one email, one stock movement.
Never delivers: the payment does not succeed, and its attempt expires after about 30 minutes.
Cash payments
Act on each test code with the buttons of the voucher page, or with the test helpers. What each outcome does is in Test cash scenarios.
Simulate payment, or pay
Fulfils the order on payment_intent.succeeded.
expire
Frees the order or the stock, and lets your customer get a new code.
pay with another amount
Delivers nothing: the payment gets a review and nothing is credited. It waits.
expire, then pay
The same: a review, nothing credited, nothing delivered.
A cash payment of 5000
Handles the refusal, amount_too_small: offer only the card for small amounts.
Refunds
Refund part of a succeeded card payment
Records what was refunded; the payment intent's amount_refunded grows.
Refund the rest
Marks the order refunded when the refund succeeds.
Try to refund a cash payment
Handles the refusal, refund_unsupported_payment_method: your own process for cash takes over. See Refunds.
Webhooks
Send your handler a delivery with a changed body (Test your handler)
Answers 400 and does nothing else.
Resend a delivery on the dashboard
Handles it once: the event's id is already known. See deduplication.
Stop your server, pay, start it again, then Resend or wait for the retry
Fulfils the order when the delivery gets through.
Requests
Send a creation twice with the same Idempotency-Key
One payment; the second answer carries Idempotent-Replayed: true. See Idempotency.
In your own tests, answer your client with a 429
Waits for Retry-After, then retries with the same key. See Rate limits.
A test key with one character missing
Handles 401, api_key_invalid: an alert for you, never a retry.
An automated test: a cash code that expires
Your own test suite can run any of these scenarios against the sandbox. This one creates a cash
payment, confirms it with your customer's name and city, expires its code with the test helper, then
waits until the payment is back in requires_payment_method with voucher_expired.
Each creation and confirmation carries its own Idempotency-Key, made fresh for every run. The
confirmation's answer names the attempt in latest_charge: the helper acts on it.
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");
}What your customers see
Your customers pay on our pages, mostly on a phone. Look at them the way your customers will:
- Open a checkout session's
urlon a phone, in French, English and Arabic (the page follows the browser's language). Pay by card, then ask for a cash code. - On the voucher page, print the voucher and share it: the code, the amount to pay and the deadline must read clearly.
- Leave the page for your shop, then open the session's
urlagain: a customer who left can come back to a session that is still open.
Let the dashboard help
- The Integrate card on Home ticks itself as your test payments, webhooks and cash codes come in.
- In test mode, Generate sample payments on Home fills the screens with sample payments and customers, to see what each page shows.
- Delete all test data, under Settings → Organization, starts you again from a clean sandbox. Your keys and your team are kept.
Next steps
When every scenario on this page passes, read the Go-live checklist.