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.

Open as Markdown
On this page
  1. Before you start
  2. Card payments
  3. Cash payments
  4. Refunds
  5. Webhooks
  6. Requests
  7. An automated test: a cash code that expires
  8. What your customers see
  9. Let the dashboard help
  10. Next steps

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.

Card payments

Pay each scenario on the hosted page with the test card named, or without a browser through the test helpers.

ScenarioHowWhat your integration does
Success
4242 4242 4242 4242

Fulfils the order once, on payment_intent.succeeded.

Declined
4000 0000 0000 0002

Shows that the payment failed and lets your customer try again. The order stays open.

3-D Secure
4000 0000 0000 3220

once with Approve and once with Fail

Approved: as a success. Failed: as a decline, with authentication_failed.

Slow confirmation
4000 0000 0000 0077

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.

Repeated notifications
4000 0000 0000 0085

One fulfilment: one email, one stock movement.

Lost confirmation
4000 0000 0000 0093

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.

ScenarioHowWhat your integration does
Paid

Simulate payment, or pay

Fulfils the order on payment_intent.succeeded.

Expired

expire

Frees the order or the stock, and lets your customer get a new code.

Paid another amount

pay with another amount

Delivers nothing: the payment gets a review and nothing is credited. It waits.

Paid after expiry

expire, then pay

The same: a review, nothing credited, nothing delivered.

Under 100.00 MAD

A cash payment of 5000

Handles the refusal, amount_too_small: offer only the card for small amounts.

Refunds

ScenarioHowWhat your integration does
Partial card refund

Refund part of a succeeded card payment

Records what was refunded; the payment intent's amount_refunded grows.

Full card refund

Refund the rest

Marks the order refunded when the refund succeeds.

Cash refund

Try to refund a cash payment

Handles the refusal, refund_unsupported_payment_method: your own process for cash takes over. See Refunds.

Webhooks

ScenarioHowWhat your integration does
A wrong signature

Send your handler a delivery with a changed body (Test your handler)

Answers 400 and does nothing else.

The same event twice

Resend a delivery on the dashboard

Handles it once: the event's id is already known. See deduplication.

Your endpoint is down

Stop your server, pay, start it again, then Resend or wait for the retry

Fulfils the order when the delivery gets through.

Requests

ScenarioHowWhat your integration does
The same creation twice

Send a creation twice with the same Idempotency-Key

One payment; the second answer carries Idempotent-Replayed: true. See Idempotency.

Too many requests

In your own tests, answer your client with a 429

Waits for Retry-After, then retries with the same key. See Rate limits.

A wrong key

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.

  • curl
  • Node.js
  • TypeScript
  • Python
  • PHP
  • Ruby
  • Go
  • Java
  • C#
  • Rust
cash-expiry.test.mjscash-expiry.test.tscash_expiry_test.pycash_expiry_test.phpcash_expiry_test.rbcash_expiry_test.goCashExpiryTest.javaCashExpiryTest.csmain.rs
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
end
package 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 url on 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 url again: 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.