Payments
Hosted checkout
A payment page we host for card and cash, in your colours. How to create a checkout session, what your customer sees, and how a session ends.
On this page
Hosted checkout is a payment page that AdProMaxPay hosts for you. Your server creates a checkout session, your customer pays on the page by card or in cash, and your server learns the result from a webhook. You never handle a card number, and the same session works for both methods.
The page:
- shows your logo, your business name, the amount and your support contact;
- speaks French, English and Arabic: it follows your customer's browser, falls back to French when it cannot tell, and has a language switch;
- is made for phones first, with the Pay button always in reach;
- carries a striped TEST band in test mode, so nobody mistakes a test page for a real one.
To try it end to end in ten minutes, follow the card quickstart.
The life of a session
- open → complete: paid
- open → expired: ended unpaid, or you expired it
A session is open from its creation. It becomes complete once its payment intent succeeds, and
expired when it reaches its expires_at unpaid, or when you expire it. Each session has one
payment intent, named by its payment_intent.
Create a session
Create one session for each order your customer is about to pay, with
POST /v1/checkout/sessions. The Idempotency-Key header is required: see
Idempotency.
| Parameter | What it holds |
|---|---|
amount | The amount, an integer in centimes: 45000 is 450.00 MAD. |
currency | Always mad. |
payment_method_types | The methods your customer may choose: card, cash, or both. Cash is offered for 100.00 MAD and more. |
success_url | Where the page sends your customer after paying. https; http://localhost is accepted in test mode only. |
cancel_url | Where Back sends your customer before paying. Same rule. |
expires_at | Optional. When the session ends, in Unix seconds, between 30 minutes and 24 hours from now. 24 hours by default. |
customer | Optional. A customer (cus_…) of the same organization and mode: the payment appears in their history. |
metadata | Optional. Your own references, such as the order number: see Metadata. |
The answer is the session: its id, its url, its payment_intent and its status. Store the
payment intent's ID with your order: the events you receive name it.
curl https://pay-api.adpromax.com/v1/checkout/sessions \
-u "$APX_SECRET_KEY:" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: checkout-order-2087" \
-d '{
"amount": 45000,
"currency": "mad",
"payment_method_types": ["card", "cash"],
"success_url": "https://shop.example.com/orders/2087/thanks",
"cancel_url": "https://shop.example.com/cart",
"expires_at": '"$(($(date +%s) + 7200))"',
"customer": "cus_01k7e2f9g3h6j8k2m5n7p4q1sd",
"metadata": { "order_id": "2087" }
}'const response = await fetch('https://pay-api.adpromax.com/v1/checkout/sessions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.APX_SECRET_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': 'checkout-order-2087',
},
body: JSON.stringify({
amount: 45000,
currency: 'mad',
payment_method_types: ['card', 'cash'],
success_url: 'https://shop.example.com/orders/2087/thanks',
cancel_url: 'https://shop.example.com/cart',
// Two hours from now, in Unix seconds.
expires_at: Math.floor(Date.now() / 1000) + 2 * 60 * 60,
customer: 'cus_01k7e2f9g3h6j8k2m5n7p4q1sd',
metadata: { order_id: '2087' },
}),
})
const session = await response.json()
if (!response.ok)
throw new Error(session.error.message)
console.log(session.payment_intent, session.url)interface CheckoutSession {
id: string
object: 'checkout.session'
amount: number
currency: string
payment_intent: string
status: string
url: string
expires_at: number
customer: string | null
metadata: Record<string, string>
}
interface ApiError {
error: { type: string, code: string, message: string, request_id: string }
}
const response = await fetch('https://pay-api.adpromax.com/v1/checkout/sessions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.APX_SECRET_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': 'checkout-order-2087',
},
body: JSON.stringify({
amount: 45000,
currency: 'mad',
payment_method_types: ['card', 'cash'],
success_url: 'https://shop.example.com/orders/2087/thanks',
cancel_url: 'https://shop.example.com/cart',
// Two hours from now, in Unix seconds.
expires_at: Math.floor(Date.now() / 1000) + 2 * 60 * 60,
customer: 'cus_01k7e2f9g3h6j8k2m5n7p4q1sd',
metadata: { order_id: '2087' },
}),
})
if (!response.ok) {
const { error } = (await response.json()) as ApiError
throw new Error(`${error.code}: ${error.message}`)
}
const session = (await response.json()) as CheckoutSession
console.log(session.payment_intent, session.url)import os
import time
import requests
response = requests.post(
"https://pay-api.adpromax.com/v1/checkout/sessions",
auth=(os.environ["APX_SECRET_KEY"], ""),
headers={"Idempotency-Key": "checkout-order-2087"},
json={
"amount": 45000,
"currency": "mad",
"payment_method_types": ["card", "cash"],
"success_url": "https://shop.example.com/orders/2087/thanks",
"cancel_url": "https://shop.example.com/cart",
# Two hours from now, in Unix seconds.
"expires_at": int(time.time()) + 2 * 60 * 60,
"customer": "cus_01k7e2f9g3h6j8k2m5n7p4q1sd",
"metadata": {"order_id": "2087"},
},
)
session = response.json()
response.raise_for_status()
print(session["payment_intent"], session["url"])<?php
$ch = curl_init('https://pay-api.adpromax.com/v1/checkout/sessions');
curl_setopt_array($ch, [
CURLOPT_USERPWD => getenv('APX_SECRET_KEY') . ':',
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Idempotency-Key: checkout-order-2087',
],
CURLOPT_POSTFIELDS => json_encode([
'amount' => 45000,
'currency' => 'mad',
'payment_method_types' => ['card', 'cash'],
'success_url' => 'https://shop.example.com/orders/2087/thanks',
'cancel_url' => 'https://shop.example.com/cart',
// Two hours from now, in Unix seconds.
'expires_at' => time() + 2 * 60 * 60,
'customer' => 'cus_01k7e2f9g3h6j8k2m5n7p4q1sd',
'metadata' => ['order_id' => '2087'],
]),
CURLOPT_RETURNTRANSFER => true,
]);
$session = json_decode(curl_exec($ch), true);
echo $session['payment_intent'], ' ', $session['url'], PHP_EOL;require 'json'
require 'net/http'
uri = URI('https://pay-api.adpromax.com/v1/checkout/sessions')
request = Net::HTTP::Post.new(uri)
request.basic_auth(ENV.fetch('APX_SECRET_KEY'), '')
request['Content-Type'] = 'application/json'
request['Idempotency-Key'] = 'checkout-order-2087'
request.body = JSON.generate(
amount: 45000,
currency: 'mad',
payment_method_types: %w[card cash],
success_url: 'https://shop.example.com/orders/2087/thanks',
cancel_url: 'https://shop.example.com/cart',
# Two hours from now, in Unix seconds.
expires_at: Time.now.to_i + 2 * 60 * 60,
customer: 'cus_01k7e2f9g3h6j8k2m5n7p4q1sd',
metadata: { order_id: '2087' }
)
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
http.request(request)
end
session = JSON.parse(response.body)
raise session.dig('error', 'message') unless response.is_a?(Net::HTTPSuccess)
puts "#{session['payment_intent']} #{session['url']}"package main
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"time"
)
type CheckoutSession struct {
ID string `json:"id"`
PaymentIntent string `json:"payment_intent"`
Status string `json:"status"`
URL string `json:"url"`
ExpiresAt int64 `json:"expires_at"`
}
type APIError struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func main() {
// Two hours from now, in Unix seconds.
expiresAt := time.Now().Add(2 * time.Hour).Unix()
body, err := json.Marshal(map[string]any{
"amount": 45000,
"currency": "mad",
"payment_method_types": []string{"card", "cash"},
"success_url": "https://shop.example.com/orders/2087/thanks",
"cancel_url": "https://shop.example.com/cart",
"expires_at": expiresAt,
"customer": "cus_01k7e2f9g3h6j8k2m5n7p4q1sd",
"metadata": map[string]string{"order_id": "2087"},
})
if err != nil {
log.Fatal(err)
}
req, err := http.NewRequest(http.MethodPost,
"https://pay-api.adpromax.com/v1/checkout/sessions", bytes.NewReader(body))
if err != nil {
log.Fatal(err)
}
req.SetBasicAuth(os.Getenv("APX_SECRET_KEY"), "")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "checkout-order-2087")
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode >= 400 {
var apiErr APIError
json.NewDecoder(res.Body).Decode(&apiErr)
log.Fatal(apiErr.Error.Message)
}
var session CheckoutSession
if err := json.NewDecoder(res.Body).Decode(&session); err != nil {
log.Fatal(err)
}
fmt.Println(session.PaymentIntent, session.URL)
}import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Instant;
public class Session {
public static void main(String[] args) throws Exception {
// Two hours from now, in Unix seconds.
long expiresAt = Instant.now().getEpochSecond() + 2 * 60 * 60;
String body = """
{
"amount": 45000,
"currency": "mad",
"payment_method_types": ["card", "cash"],
"success_url": "https://shop.example.com/orders/2087/thanks",
"cancel_url": "https://shop.example.com/cart",
"expires_at": %d,
"customer": "cus_01k7e2f9g3h6j8k2m5n7p4q1sd",
"metadata": { "order_id": "2087" }
}
""".formatted(expiresAt);
HttpRequest request = HttpRequest.newBuilder(
URI.create("https://pay-api.adpromax.com/v1/checkout/sessions"))
.header("Authorization", "Bearer " + System.getenv("APX_SECRET_KEY"))
.header("Content-Type", "application/json")
.header("Idempotency-Key", "checkout-order-2087")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 400) {
throw new IllegalStateException(response.body());
}
// The session as JSON: read payment_intent and url with a JSON library.
System.out.println(response.statusCode() + " " + response.body());
}
}using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
var json = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower };
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("APX_SECRET_KEY"));
using var request = new HttpRequestMessage(
HttpMethod.Post, "https://pay-api.adpromax.com/v1/checkout/sessions");
request.Headers.Add("Idempotency-Key", "checkout-order-2087");
request.Content = JsonContent.Create(new
{
amount = 45000,
currency = "mad",
payment_method_types = new[] { "card", "cash" },
success_url = "https://shop.example.com/orders/2087/thanks",
cancel_url = "https://shop.example.com/cart",
// Two hours from now, in Unix seconds.
expires_at = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + 2 * 60 * 60,
customer = "cus_01k7e2f9g3h6j8k2m5n7p4q1sd",
metadata = new { order_id = "2087" },
});
using var response = await http.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
var failure = await response.Content.ReadFromJsonAsync<ApiError>(json);
throw new InvalidOperationException(failure?.Error.Message);
}
var session = await response.Content.ReadFromJsonAsync<CheckoutSession>(json);
Console.WriteLine($"{session!.PaymentIntent} {session.Url}");
record CheckoutSession(string Id, string PaymentIntent, string Status, string Url);
record ApiError(ApiErrorBody Error);
record ApiErrorBody(string Code, string Message);// Cargo.toml: reqwest = { version = "0.12", features = ["blocking", "json"] }
// serde = { version = "1", features = ["derive"] }
// serde_json = "1"
use std::time::{SystemTime, UNIX_EPOCH};
use serde::Deserialize;
use serde_json::json;
#[derive(Deserialize)]
struct CheckoutSession {
payment_intent: String,
url: String,
}
#[derive(Deserialize)]
struct ApiError {
error: ErrorBody,
}
#[derive(Deserialize)]
struct ErrorBody {
message: String,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Two hours from now, in Unix seconds.
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let expires_at = now + 2 * 60 * 60;
let response = reqwest::blocking::Client::new()
.post("https://pay-api.adpromax.com/v1/checkout/sessions")
.bearer_auth(std::env::var("APX_SECRET_KEY")?)
.header("Idempotency-Key", "checkout-order-2087")
.json(&json!({
"amount": 45000,
"currency": "mad",
"payment_method_types": ["card", "cash"],
"success_url": "https://shop.example.com/orders/2087/thanks",
"cancel_url": "https://shop.example.com/cart",
"expires_at": expires_at,
"customer": "cus_01k7e2f9g3h6j8k2m5n7p4q1sd",
"metadata": { "order_id": "2087" }
}))
.send()?;
if !response.status().is_success() {
let failure: ApiError = response.json()?;
return Err(failure.error.message.into());
}
let session: CheckoutSession = response.json()?;
println!("{} {}", session.payment_intent, session.url);
Ok(())
}{
"id": "cs_01k7e3m5q8w2r6t9y4b7n1v5ca",
"amount": 45000,
"currency": "mad",
"payment_method_types": ["card", "cash"],
"payment_intent": "pi_01k7e3m5px4z8c2v6b9n3m7hra",
"customer": "cus_01k7e2f9g3h6j8k2m5n7p4q1sd",
"status": "open",
"url": "https://pay.example.com/c/cs_01k7e3m5q8w2r6t9y4b7n1v5ca#…",
"success_url": "https://shop.example.com/orders/2087/thanks",
"cancel_url": "https://shop.example.com/cart",
"expires_at": 1790854200,
"metadata": { "order_id": "2087" },
"livemode": false
}Send your customer to the page
Redirect your customer's browser to the session's url with an HTTP 303, as in
the quickstart.
Keep the whole address, including the part after #. That part is your customer's access to this
session: browsers never send it to a server, so it stays out of logs. Do not log it, shorten it or
send it to anyone but your customer.
What your customer sees
First, the amount and a choice between the methods you offered.
By card:
- Your customer chooses card and presses Pay. The page prepares the payment, then explains that it is taking them to the secure card page of our card partner. In test mode, that page is our simulation marked TEST, which takes only the test cards.
- They type their card on that page. If their bank asks for 3-D Secure, they confirm there.
- They come back to our page, which shows "Confirming with your bank…" until the payment is confirmed, then the success page.
- After 5 seconds, the success page sends them to your
success_url.
If the card is declined, the page gives the reason and offers Try another card, or Pay in cash instead when you offered cash and the amount is at least 100.00 MAD. See Card payments.
In cash: your customer gives their full name and their city (a phone number is optional), then receives a voucher with a payment code to pay at a partner payment agent. See Cash payments.
For both methods, your customer may give an email address for a receipt; the receipt is sent only when they do.
The page's ← Back link, which names your business, sends your customer to your cancel_url. The
session stays open until it expires, so the same url still works if they come back.
Know when it is paid
Fulfil the order on one of these events, never on the return to your success_url:
| Event | When |
|---|---|
payment_intent.succeeded | The session's payment intent succeeded. |
checkout.session.completed | The session was paid: it names its payment_intent. |
checkout.session.expired | The session ended unpaid. Free the order or the stock. |
You also receive the payment intent's other events, such as payment_intent.payment_failed after a
declined card. Read Redirects and webhooks
for why the return proves nothing.
A cash code your customer already received keeps its own deadline, about three days, even when the
session's expires_at comes first: they can still pay it.
Expire a session
If the order is canceled or the stock is gone, expire the open session yourself with
POST /v1/checkout/sessions/{id}/expire. The session becomes expired and its unpaid payment intent
is canceled: your customer can no longer pay it. Only an open session can be expired.
To read a session again, use GET /v1/checkout/sessions/{id}; to list them,
GET /v1/checkout/sessions.
curl -X POST \
https://pay-api.adpromax.com/v1/checkout/sessions/cs_01k7e3m5q8w2r6t9y4b7n1v5ca/expire \
-u "$APX_SECRET_KEY:"const id = 'cs_01k7e3m5q8w2r6t9y4b7n1v5ca'
const response = await fetch(`https://pay-api.adpromax.com/v1/checkout/sessions/${id}/expire`, {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.APX_SECRET_KEY}` },
})
const session = await response.json()
console.log(session.status) // expiredinterface CheckoutSession {
id: string
object: 'checkout.session'
status: string
}
const id = 'cs_01k7e3m5q8w2r6t9y4b7n1v5ca'
const response = await fetch(`https://pay-api.adpromax.com/v1/checkout/sessions/${id}/expire`, {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.APX_SECRET_KEY}` },
})
const session = (await response.json()) as CheckoutSession
console.log(session.status) // expiredimport os
import requests
session_id = "cs_01k7e3m5q8w2r6t9y4b7n1v5ca"
response = requests.post(
f"https://pay-api.adpromax.com/v1/checkout/sessions/{session_id}/expire",
auth=(os.environ["APX_SECRET_KEY"], ""),
)
print(response.json()["status"]) # expired<?php
$id = 'cs_01k7e3m5q8w2r6t9y4b7n1v5ca';
$ch = curl_init("https://pay-api.adpromax.com/v1/checkout/sessions/{$id}/expire");
curl_setopt_array($ch, [
CURLOPT_USERPWD => getenv('APX_SECRET_KEY') . ':',
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
]);
$session = json_decode(curl_exec($ch), true);
echo $session['status'], PHP_EOL; // expiredrequire 'json'
require 'net/http'
id = 'cs_01k7e3m5q8w2r6t9y4b7n1v5ca'
uri = URI("https://pay-api.adpromax.com/v1/checkout/sessions/#{id}/expire")
request = Net::HTTP::Post.new(uri)
request.basic_auth(ENV.fetch('APX_SECRET_KEY'), '')
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
http.request(request)
end
puts JSON.parse(response.body)['status'] # expiredpackage main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
)
type CheckoutSession struct {
ID string `json:"id"`
Status string `json:"status"`
}
func main() {
id := "cs_01k7e3m5q8w2r6t9y4b7n1v5ca"
req, err := http.NewRequest(http.MethodPost,
"https://pay-api.adpromax.com/v1/checkout/sessions/"+id+"/expire", nil)
if err != nil {
log.Fatal(err)
}
req.SetBasicAuth(os.Getenv("APX_SECRET_KEY"), "")
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
var session CheckoutSession
if err := json.NewDecoder(res.Body).Decode(&session); err != nil {
log.Fatal(err)
}
fmt.Println(session.Status) // expired
}import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class Expire {
public static void main(String[] args) throws Exception {
String id = "cs_01k7e3m5q8w2r6t9y4b7n1v5ca";
HttpRequest request = HttpRequest.newBuilder(
URI.create("https://pay-api.adpromax.com/v1/checkout/sessions/" + id + "/expire"))
.header("Authorization", "Bearer " + System.getenv("APX_SECRET_KEY"))
.POST(HttpRequest.BodyPublishers.noBody())
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
// The session as JSON: its status is now "expired".
System.out.println(response.body());
}
}using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
var json = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower };
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("APX_SECRET_KEY"));
var id = "cs_01k7e3m5q8w2r6t9y4b7n1v5ca";
using var response = await http.PostAsync(
$"https://pay-api.adpromax.com/v1/checkout/sessions/{id}/expire", null);
var session = await response.Content.ReadFromJsonAsync<CheckoutSession>(json);
Console.WriteLine(session!.Status); // expired
record CheckoutSession(string Id, string Status);// Cargo.toml: reqwest = { version = "0.12", features = ["blocking", "json"] }
// serde = { version = "1", features = ["derive"] }
use serde::Deserialize;
#[derive(Deserialize)]
struct CheckoutSession {
status: String,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let id = "cs_01k7e3m5q8w2r6t9y4b7n1v5ca";
let session: CheckoutSession = reqwest::blocking::Client::new()
.post(format!("https://pay-api.adpromax.com/v1/checkout/sessions/{id}/expire"))
.bearer_auth(std::env::var("APX_SECRET_KEY")?)
.send()?
.json()?;
println!("{}", session.status); // expired
Ok(())
}Your branding
Set the page's look once, on the dashboard under Settings → Branding:
- a logo, in PNG, JPEG or WebP, up to 512 KB;
- the display name and your support email and phone;
- a button colour: if white text would be hard to read on it, it is darkened until it is readable, and the settings page tells you;
- a white background, or one lightly tinted with your colour.
The voucher page of a cash payment uses the same settings.
No code at all
A payment link, made on the dashboard, opens the same hosted page for anyone who has the link. See No code: a payment link.