# Démarrage rapide : paiement par carte avec la page hébergée

Créez une session de paiement, payez-la avec une carte de test et apprenez le résultat par un webhook. Une dizaine de minutes, en mode test.

Votre serveur crée une **session de paiement** (*checkout session*), votre client paie sur la page de
paiement hébergée, et votre serveur apprend le résultat par un **webhook**. Tout se passe ici en mode
test : aucun argent réel ne circule, et la page carte est une simulation marquée TEST.

Il vous faut un compte bac à sable. [Créez-en un](https://pay.adpromax.com/auth/signup) : ses clés de test
fonctionnent tout de suite.

## 1. Définir votre clé de test

Votre clé secrète de test commence par `apx_sk_test_`. Elle s’affiche une fois à l’inscription ; les
clés suivantes se créent sur le tableau de bord, sous **Développeurs → Clés d’API**. Placez-la dans
une variable d’environnement sur la machine qui exécute les exemples.

Les exemples de cette page la lisent dans `APX_SECRET_KEY`. Pour vérifier la clé d’abord, demandez à
l’API qui vous êtes : voir [Vos clés d’API](https://pay-docs.adpromax.com/fr/get-started/api-keys).

```bash title="Terminal"
export APX_SECRET_KEY="apx_sk_test_…"
```

## 2. Créer une session de paiement

Quand votre client clique sur *Payer*, votre serveur crée une session de paiement avec :

* `amount`, en centimes : `25000` vaut 250,00 MAD ;
* `currency` : toujours `mad` ;
* `payment_method_types` : `["card"]` ici ; ajoutez `"cash"` pour laisser votre client choisir ;
* `success_url` et `cancel_url` : là où la page hébergée renvoie votre client ;
* `metadata` : vos propres références, comme le numéro de commande.

L’en-tête `Idempotency-Key` est obligatoire. Si la requête échoue sur le réseau, renvoyez-la avec la
même clé : vous obtenez la même session, jamais une seconde. Voir
[Idempotence](https://pay-docs.adpromax.com/fr/api-reference/idempotency).

La réponse contient l’`url` de la session et son `payment_intent`. Enregistrez l’identifiant de
l’intention de paiement avec votre commande : le webhook de l’étape 6 le nomme.

```bash
curl https://pay-api.adpromax.com/v1/checkout/sessions \
  -u "$APX_SECRET_KEY:" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: checkout-order-1042" \
  -d '{
    "amount": 25000,
    "currency": "mad",
    "payment_method_types": ["card"],
    "success_url": "https://shop.example.com/thanks",
    "cancel_url": "https://shop.example.com/cart",
    "metadata": { "order_id": "1042" }
  }'
```

```js title="checkout.mjs"
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-1042',
  },
  body: JSON.stringify({
    amount: 25000,
    currency: 'mad',
    payment_method_types: ['card'],
    success_url: 'https://shop.example.com/thanks',
    cancel_url: 'https://shop.example.com/cart',
    metadata: { order_id: '1042' },
  }),
})
const session = await response.json()
if (!response.ok)
  throw new Error(session.error.message)
console.log(session.payment_intent, session.url)
```

```ts title="checkout.ts"
interface CheckoutSession {
  id: string
  object: 'checkout.session'
  amount: number
  currency: string
  payment_intent: string
  status: string
  url: string
  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-1042',
  },
  body: JSON.stringify({
    amount: 25000,
    currency: 'mad',
    payment_method_types: ['card'],
    success_url: 'https://shop.example.com/thanks',
    cancel_url: 'https://shop.example.com/cart',
    metadata: { order_id: '1042' },
  }),
})
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)
```

```python title="checkout.py"
import os

import requests

response = requests.post(
    "https://pay-api.adpromax.com/v1/checkout/sessions",
    auth=(os.environ["APX_SECRET_KEY"], ""),
    headers={"Idempotency-Key": "checkout-order-1042"},
    json={
        "amount": 25000,
        "currency": "mad",
        "payment_method_types": ["card"],
        "success_url": "https://shop.example.com/thanks",
        "cancel_url": "https://shop.example.com/cart",
        "metadata": {"order_id": "1042"},
    },
)
session = response.json()
if not response.ok:
    raise RuntimeError(session["error"]["message"])
print(session["payment_intent"], session["url"])
```

```php title="checkout.php"
<?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-1042',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'amount' => 25000,
        'currency' => 'mad',
        'payment_method_types' => ['card'],
        'success_url' => 'https://shop.example.com/thanks',
        'cancel_url' => 'https://shop.example.com/cart',
        'metadata' => ['order_id' => '1042'],
    ]),
    CURLOPT_RETURNTRANSFER => true,
]);
$session = json_decode(curl_exec($ch), true);
if (curl_getinfo($ch, CURLINFO_RESPONSE_CODE) >= 400) {
    throw new RuntimeException($session['error']['message']);
}
echo $session['payment_intent'], ' ', $session['url'], PHP_EOL;
```

```ruby title="checkout.rb"
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-1042'
request.body = JSON.generate(
  amount: 25000,
  currency: 'mad',
  payment_method_types: ['card'],
  success_url: 'https://shop.example.com/thanks',
  cancel_url: 'https://shop.example.com/cart',
  metadata: { order_id: '1042' }
)

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']}"
```

```go title="checkout.go"
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"os"
)

type CheckoutSession struct {
	ID            string `json:"id"`
	PaymentIntent string `json:"payment_intent"`
	Status        string `json:"status"`
	URL           string `json:"url"`
}

type APIError struct {
	Error struct {
		Code    string `json:"code"`
		Message string `json:"message"`
	} `json:"error"`
}

func main() {
	body, err := json.Marshal(map[string]any{
		"amount":               25000,
		"currency":             "mad",
		"payment_method_types": []string{"card"},
		"success_url":          "https://shop.example.com/thanks",
		"cancel_url":           "https://shop.example.com/cart",
		"metadata":             map[string]string{"order_id": "1042"},
	})
	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-1042")

	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)
}
```

```java title="Checkout.java"
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class Checkout {
    public static void main(String[] args) throws Exception {
        String body = """
            {
              "amount": 25000,
              "currency": "mad",
              "payment_method_types": ["card"],
              "success_url": "https://shop.example.com/thanks",
              "cancel_url": "https://shop.example.com/cart",
              "metadata": { "order_id": "1042" }
            }
            """;
        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-1042")
            .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());
    }
}
```

```csharp title="Checkout.cs"
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-1042");
request.Content = JsonContent.Create(new
{
    amount = 25000,
    currency = "mad",
    payment_method_types = new[] { "card" },
    success_url = "https://shop.example.com/thanks",
    cancel_url = "https://shop.example.com/cart",
    metadata = new { order_id = "1042" },
});

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);
```

```rust title="main.rs"
// Cargo.toml: reqwest = { version = "0.12", features = ["blocking", "json"] }
//             serde = { version = "1", features = ["derive"] }
//             serde_json = "1"
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>> {
    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-1042")
        .json(&json!({
            "amount": 25000,
            "currency": "mad",
            "payment_method_types": ["card"],
            "success_url": "https://shop.example.com/thanks",
            "cancel_url": "https://shop.example.com/cart",
            "metadata": { "order_id": "1042" }
        }))
        .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(())
}
```

```json title="200 OK" response
{
  "id": "cs_01k7d2h6w4q9m3x8v5c2n7b1rt",
  "amount": 25000,
  "currency": "mad",
  "payment_method_types": ["card"],
  "payment_intent": "pi_01k7d2h6w0f3r8t5y2v9n4p6qa",
  "status": "open",
  "url": "https://pay.example.com/c/cs_01k7d2h6w4q9m3x8v5c2n7b1rt#…",
  "success_url": "https://shop.example.com/thanks",
  "cancel_url": "https://shop.example.com/cart",
  "expires_at": 1790937600,
  "metadata": { "order_id": "1042" },
  "livemode": false
}
```

## 3. Envoyer votre client sur la page

Redirigez le navigateur de votre client vers l’`url` de la session, avec un HTTP `303`. Gardez
l’adresse entière, y compris la partie après `#` : c’est l’accès de votre client à cette session.

La page hébergée affiche le montant, le nom de votre entreprise et les moyens de paiement. Une
session reste ouverte 24 heures, sauf si vous fixez `expires_at` (entre 30 minutes et 24 heures).

```bash
# curl does not follow your customer: read where your server sends them.
curl -si https://shop.example.com/checkout | grep -i '^location:'
```

```js title="express.mjs"
res.redirect(303, session.url)
```

```ts title="express.ts"
app.post('/checkout', async (req: Request, res: Response) => {
  const session = await createCheckoutSession(req.body.orderId)
  res.redirect(303, session.url)
})
```

```python title="app.py"
return redirect(session["url"], code=303)
```

```php title="checkout.php"
header('Location: ' . $session['url'], true, 303);
```

```ruby title="app.rb"
redirect session['url'], 303
```

```go title="handler.go"
http.Redirect(w, r, session.URL, http.StatusSeeOther)
```

```java title="CheckoutController.java"
return ResponseEntity.status(HttpStatus.SEE_OTHER)
    .location(URI.create(session.url()))
    .build();
```

```csharp title="Program.cs"
// Results.Redirect answers 302: set the 303 yourself.
context.Response.Headers.Location = session.Url;
context.Response.StatusCode = StatusCodes.Status303SeeOther;
```

```rust title="main.rs"
// axum's Redirect::to answers 303 See Other.
Redirect::to(&session.url)
```

## 4. Payer avec une carte de test

Ouvrez l’`url` de la session dans un navigateur et choisissez la carte. La page de paiement du mode
test est une simulation marquée TEST ; tapez-y ce numéro de carte. Si elle demande une date
d’expiration ou un code de sécurité, mettez n’importe quelle date future et trois chiffres
quelconques.

Après le paiement, la page vous renvoie vers votre `success_url`.

D’autres numéros sont refusés, demandent le 3-D Secure ou répondent lentement : voir
[Cartes de test](https://pay-docs.adpromax.com/fr/sandbox/test-cards). Pour payer sans navigateur, dans des tests automatisés,
utilisez les [assistants de test](https://pay-docs.adpromax.com/fr/sandbox/test-helpers).

Le retour sur `success_url` ne prouve pas le paiement : votre client peut fermer l’onglet avant de
revenir, ou ouvrir cette adresse à la main. Livrez la commande sur le webhook des étapes suivantes,
ou après avoir lu le `status` de l’intention de paiement.

- `4242 4242 4242 4242` (Succès): Réussit tout de suite.

## 5. Créer un endpoint de webhook

Quand le paiement réussit, AdProMaxPay envoie l’événement `payment_intent.succeeded` à votre endpoint
de webhook. Créez l’endpoint une fois, pour le mode test, sur le tableau de bord
(**Développeurs → Webhooks**) ou avec l’API, comme ici. La réponse montre le secret de signature de
l’endpoint (`apx_whsec_…`) cette seule fois : enregistrez-le dans `APX_WEBHOOK_SECRET`.

L’adresse doit être publique et en `https`. Pour recevoir les événements sur votre propre
ordinateur, exposez-le par un tunnel HTTPS et utilisez l’adresse du tunnel. Remplacez le
`shop.example.com` de l’exemple par votre propre hôte : une adresse dont l’hôte ne se résout pas est
refusée avec `url_not_allowed`.

```bash
curl https://pay-api.adpromax.com/v1/webhook_endpoints \
  -u "$APX_SECRET_KEY:" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://shop.example.com/webhooks/payments",
    "enabled_events": [
      "payment_intent.succeeded",
      "payment_intent.payment_failed"
    ]
  }'
```

```js title="endpoint.mjs"
const response = await fetch('https://pay-api.adpromax.com/v1/webhook_endpoints', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.APX_SECRET_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    url: 'https://shop.example.com/webhooks/payments',
    enabled_events: [
      'payment_intent.succeeded',
      'payment_intent.payment_failed',
    ],
  }),
})
const endpoint = await response.json()
if (!response.ok)
  throw new Error(endpoint.error.message)
// The secret is shown this once: store it.
console.log(endpoint.id, endpoint.secret)
```

```ts title="endpoint.ts"
interface WebhookEndpoint {
  id: string
  object: 'webhook_endpoint'
  url: string
  enabled_events: string[]
  status: 'enabled' | 'disabled'
  livemode: boolean
  /** Only in the answer that created the endpoint. */
  secret?: string
}

interface ApiError {
  error: { type: string, code: string, message: string, request_id: string }
}

const response = await fetch('https://pay-api.adpromax.com/v1/webhook_endpoints', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.APX_SECRET_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    url: 'https://shop.example.com/webhooks/payments',
    enabled_events: [
      'payment_intent.succeeded',
      'payment_intent.payment_failed',
    ],
  }),
})
if (!response.ok) {
  const { error } = (await response.json()) as ApiError
  throw new Error(`${error.code}: ${error.message}`)
}
const endpoint = (await response.json()) as WebhookEndpoint
// The secret is shown this once: store it.
console.log(endpoint.id, endpoint.secret)
```

```python title="endpoint.py"
import os

import requests

response = requests.post(
    "https://pay-api.adpromax.com/v1/webhook_endpoints",
    auth=(os.environ["APX_SECRET_KEY"], ""),
    json={
        "url": "https://shop.example.com/webhooks/payments",
        "enabled_events": [
            "payment_intent.succeeded",
            "payment_intent.payment_failed",
        ],
    },
)
endpoint = response.json()
if not response.ok:
    raise RuntimeError(endpoint["error"]["message"])
# The secret is shown this once: store it.
print(endpoint["id"], endpoint["secret"])
```

```php title="endpoint.php"
<?php
$ch = curl_init('https://pay-api.adpromax.com/v1/webhook_endpoints');
curl_setopt_array($ch, [
    CURLOPT_USERPWD => getenv('APX_SECRET_KEY') . ':',
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
    CURLOPT_POSTFIELDS => json_encode([
        'url' => 'https://shop.example.com/webhooks/payments',
        'enabled_events' => [
            'payment_intent.succeeded',
            'payment_intent.payment_failed',
        ],
    ]),
    CURLOPT_RETURNTRANSFER => true,
]);
$endpoint = json_decode(curl_exec($ch), true);
if (curl_getinfo($ch, CURLINFO_RESPONSE_CODE) >= 400) {
    throw new RuntimeException($endpoint['error']['message']);
}
// The secret is shown this once: store it.
echo $endpoint['id'], ' ', $endpoint['secret'], PHP_EOL;
```

```ruby title="endpoint.rb"
require 'json'
require 'net/http'

uri = URI('https://pay-api.adpromax.com/v1/webhook_endpoints')
request = Net::HTTP::Post.new(uri)
request.basic_auth(ENV.fetch('APX_SECRET_KEY'), '')
request['Content-Type'] = 'application/json'
request.body = JSON.generate(
  url: 'https://shop.example.com/webhooks/payments',
  enabled_events: [
    'payment_intent.succeeded',
    'payment_intent.payment_failed'
  ]
)

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end
endpoint = JSON.parse(response.body)
raise endpoint.dig('error', 'message') unless response.is_a?(Net::HTTPSuccess)

# The secret is shown this once: store it.
puts "#{endpoint['id']} #{endpoint['secret']}"
```

```go title="endpoint.go"
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"os"
)

type WebhookEndpoint struct {
	ID            string   `json:"id"`
	URL           string   `json:"url"`
	EnabledEvents []string `json:"enabled_events"`
	Status        string   `json:"status"`
	Secret        string   `json:"secret"`
}

type APIError struct {
	Error struct {
		Code    string `json:"code"`
		Message string `json:"message"`
	} `json:"error"`
}

func main() {
	body, err := json.Marshal(map[string]any{
		"url": "https://shop.example.com/webhooks/payments",
		"enabled_events": []string{
			"payment_intent.succeeded",
			"payment_intent.payment_failed",
		},
	})
	if err != nil {
		log.Fatal(err)
	}
	req, err := http.NewRequest(http.MethodPost,
		"https://pay-api.adpromax.com/v1/webhook_endpoints", bytes.NewReader(body))
	if err != nil {
		log.Fatal(err)
	}
	req.SetBasicAuth(os.Getenv("APX_SECRET_KEY"), "")
	req.Header.Set("Content-Type", "application/json")

	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 endpoint WebhookEndpoint
	if err := json.NewDecoder(res.Body).Decode(&endpoint); err != nil {
		log.Fatal(err)
	}
	// The secret is shown this once: store it.
	fmt.Println(endpoint.ID, endpoint.Secret)
}
```

```java title="Endpoint.java"
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class Endpoint {
    public static void main(String[] args) throws Exception {
        String body = """
            {
              "url": "https://shop.example.com/webhooks/payments",
              "enabled_events": [
                "payment_intent.succeeded",
                "payment_intent.payment_failed"
              ]
            }
            """;
        HttpRequest request = HttpRequest.newBuilder(
                URI.create("https://pay-api.adpromax.com/v1/webhook_endpoints"))
            .header("Authorization", "Bearer " + System.getenv("APX_SECRET_KEY"))
            .header("Content-Type", "application/json")
            .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 secret is shown this once: store it.
        System.out.println(response.statusCode() + " " + response.body());
    }
}
```

```csharp title="Endpoint.cs"
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 response = await http.PostAsJsonAsync(
    "https://pay-api.adpromax.com/v1/webhook_endpoints",
    new
    {
        url = "https://shop.example.com/webhooks/payments",
        enabled_events = new[]
        {
            "payment_intent.succeeded",
            "payment_intent.payment_failed",
        },
    });
if (!response.IsSuccessStatusCode)
{
    var failure = await response.Content.ReadFromJsonAsync<ApiError>(json);
    throw new InvalidOperationException(failure?.Error.Message);
}
var endpoint = await response.Content.ReadFromJsonAsync<WebhookEndpoint>(json);
// The secret is shown this once: store it.
Console.WriteLine($"{endpoint!.Id} {endpoint.Secret}");

record WebhookEndpoint(
    string Id, string Url, string[] EnabledEvents, string Status, string? Secret);
record ApiError(ApiErrorBody Error);
record ApiErrorBody(string Code, string Message);
```

```rust title="main.rs"
// Cargo.toml: reqwest = { version = "0.12", features = ["blocking", "json"] }
//             serde = { version = "1", features = ["derive"] }
//             serde_json = "1"
use serde::Deserialize;
use serde_json::json;

#[derive(Deserialize)]
struct WebhookEndpoint {
    id: String,
    /// Only in the answer that created the endpoint.
    secret: Option<String>,
}

#[derive(Deserialize)]
struct ApiError {
    error: ErrorBody,
}

#[derive(Deserialize)]
struct ErrorBody {
    message: String,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let response = reqwest::blocking::Client::new()
        .post("https://pay-api.adpromax.com/v1/webhook_endpoints")
        .bearer_auth(std::env::var("APX_SECRET_KEY")?)
        .json(&json!({
            "url": "https://shop.example.com/webhooks/payments",
            "enabled_events": [
                "payment_intent.succeeded",
                "payment_intent.payment_failed"
            ]
        }))
        .send()?;
    if !response.status().is_success() {
        let failure: ApiError = response.json()?;
        return Err(failure.error.message.into());
    }
    let endpoint: WebhookEndpoint = response.json()?;
    // The secret is shown this once: store it.
    println!("{} {}", endpoint.id, endpoint.secret.unwrap_or_default());
    Ok(())
}
```

```json title="200 OK" response
{
  "id": "we_01k7d3a9r5t2y8v4j6p1p3s7dc",
  "object": "webhook_endpoint",
  "url": "https://shop.example.com/webhooks/payments",
  "description": null,
  "enabled_events": [
    "payment_intent.succeeded",
    "payment_intent.payment_failed"
  ],
  "api_version": "2026-11-01",
  "status": "enabled",
  "livemode": false,
  "metadata": {},
  "created": 1790851200,
  "secret": "apx_whsec_…"
}
```

## 6. Traiter l’événement

Votre gestionnaire vérifie la signature sur le corps brut, puis livre la commande qui porte
l’identifiant de l’intention de paiement. La fonction `verifySignature` vient de
[Vérifier les signatures](https://pay-docs.adpromax.com/fr/webhooks/signatures).

Répondez `200` vite, puis faites le travail lent. Un événement peut arriver plus d’une fois : voir
[Nouvelles tentatives, ordre et déduplication](https://pay-docs.adpromax.com/fr/webhooks/retries).

```bash
# curl receives nothing. From a terminal, send your handler a signed
# test delivery: 200 means it verified the signature.
SECRET="$APX_WEBHOOK_SECRET"
BODY='{"id":"evt_handler_test","object":"event","type":"payment_intent.succeeded","data":{"object":{"id":"pi_handler_test"}}}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" \
  | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.*= //')

curl -i http://localhost:4242/webhooks/payments \
  -H "Content-Type: application/json" \
  -H "X-AdProMaxPay-Signature: t=$T,v1=$SIG" \
  --data-binary "$BODY"
```

```js title="webhook.mjs"
import { Buffer } from 'node:buffer'
import { createServer } from 'node:http'
import { verifySignature } from './verify-signature.mjs'

createServer(async (request, response) => {
  const chunks = []
  for await (const chunk of request)
    chunks.push(chunk)
  // The exact bytes: verify them before parsing.
  const rawBody = Buffer.concat(chunks)
  const header = request.headers['x-adpromaxpay-signature'] ?? ''
  const secret = process.env.APX_WEBHOOK_SECRET
  if (!verifySignature(rawBody, header, secret)) {
    response.writeHead(400).end()
    return
  }
  const event = JSON.parse(rawBody.toString('utf8'))
  if (event.type === 'payment_intent.succeeded') {
    const paymentIntent = event.data.object
    // Fulfil the order that holds this payment intent, once.
    console.log('paid', paymentIntent.id)
  }
  response.writeHead(200).end()
}).listen(4242)
```

```ts title="webhook.ts"
import { Buffer } from 'node:buffer'
import { createServer } from 'node:http'
import { verifySignature } from './verify-signature.ts'

interface WebhookEvent {
  id: string
  type: string
  data: { object: { id: string, status: string } }
}

createServer(async (request, response) => {
  const chunks: Buffer[] = []
  for await (const chunk of request)
    chunks.push(chunk as Buffer)
  // The exact bytes: verify them before parsing.
  const rawBody = Buffer.concat(chunks)
  const header = request.headers['x-adpromaxpay-signature']
  const secret = process.env.APX_WEBHOOK_SECRET ?? ''
  if (typeof header !== 'string' || !verifySignature(rawBody, header, secret)) {
    response.writeHead(400).end()
    return
  }
  const event = JSON.parse(rawBody.toString('utf8')) as WebhookEvent
  if (event.type === 'payment_intent.succeeded') {
    const paymentIntent = event.data.object
    // Fulfil the order that holds this payment intent, once.
    console.log('paid', paymentIntent.id)
  }
  response.writeHead(200).end()
}).listen(4242)
```

```python title="webhook.py"
import json
import os
from http.server import BaseHTTPRequestHandler, HTTPServer

from verify_signature import verify_signature

class Webhook(BaseHTTPRequestHandler):
    def do_POST(self):
        # The exact bytes: verify them before parsing.
        length = int(self.headers.get("Content-Length", 0))
        raw_body = self.rfile.read(length)
        header = self.headers.get("X-AdProMaxPay-Signature", "")
        secret = os.environ["APX_WEBHOOK_SECRET"]
        if not verify_signature(raw_body, header, secret):
            self.send_response(400)
            self.end_headers()
            return
        event = json.loads(raw_body)
        if event["type"] == "payment_intent.succeeded":
            payment_intent = event["data"]["object"]
            # Fulfil the order that holds this payment intent, once.
            print("paid", payment_intent["id"], flush=True)
        self.send_response(200)
        self.end_headers()

HTTPServer(("", 4242), Webhook).serve_forever()
```

```php title="webhook.php"
<?php
require_once __DIR__ . '/verify_signature.php';

// The exact bytes: verify them before parsing.
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_ADPROMAXPAY_SIGNATURE'] ?? '';
$secret = getenv('APX_WEBHOOK_SECRET');
if (!verify_signature($rawBody, $header, $secret)) {
    http_response_code(400);
    exit;
}
$event = json_decode($rawBody, true);
if ($event['type'] === 'payment_intent.succeeded') {
    $paymentIntent = $event['data']['object'];
    // Fulfil the order that holds this payment intent, once.
    error_log('paid ' . $paymentIntent['id']);
}
http_response_code(200);
```

```ruby title="webhook.rb"
require 'json'
require 'webrick' # Ruby 3.0 or later: gem install webrick
require_relative 'verify_signature'

server = WEBrick::HTTPServer.new(Port: 4242)
server.mount_proc '/webhooks/payments' do |request, response|
  # The exact bytes: verify them before parsing.
  raw_body = request.body.to_s
  header = request['X-AdProMaxPay-Signature'].to_s
  secret = ENV.fetch('APX_WEBHOOK_SECRET')
  unless verify_signature(raw_body, header, secret)
    response.status = 400
    next
  end
  event = JSON.parse(raw_body)
  if event['type'] == 'payment_intent.succeeded'
    payment_intent = event['data']['object']
    # Fulfil the order that holds this payment intent, once.
    puts "paid #{payment_intent['id']}"
  end
  response.status = 200
end
trap('INT') { server.shutdown }
server.start
```

```go title="webhook.go"
package main

import (
	"encoding/json"
	"io"
	"log"
	"net/http"
	"os"
	"time"
)

type Event struct {
	Type string `json:"type"`
	Data struct {
		Object struct {
			ID string `json:"id"`
		} `json:"object"`
	} `json:"data"`
}

func webhook(w http.ResponseWriter, r *http.Request) {
	// The exact bytes: verify them before parsing.
	rawBody, err := io.ReadAll(r.Body)
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	header := r.Header.Get("X-AdProMaxPay-Signature")
	secret := os.Getenv("APX_WEBHOOK_SECRET")
	if !VerifySignature(rawBody, header, secret, time.Now()) {
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	var event Event
	if err := json.Unmarshal(rawBody, &event); err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	if event.Type == "payment_intent.succeeded" {
		// Fulfil the order that holds this payment intent, once.
		log.Println("paid", event.Data.Object.ID)
	}
	w.WriteHeader(http.StatusOK)
}

func main() {
	http.HandleFunc("POST /webhooks/payments", webhook)
	log.Fatal(http.ListenAndServe(":4242", nil))
}
```

```java title="Webhook.java"
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.security.GeneralSecurityException;

public class Webhook {
    public static void main(String[] args) throws Exception {
        String secret = System.getenv("APX_WEBHOOK_SECRET");
        HttpServer server = HttpServer.create(new InetSocketAddress(4242), 0);
        server.createContext("/webhooks/payments", exchange -> {
            // The exact bytes: verify them before parsing.
            byte[] rawBody = exchange.getRequestBody().readAllBytes();
            String header = exchange.getRequestHeaders()
                .getFirst("X-AdProMaxPay-Signature");
            boolean valid;
            try {
                valid = header != null
                    && WebhookSignature.verify(rawBody, header, secret);
            } catch (GeneralSecurityException e) {
                valid = false;
            }
            if (valid) {
                // Read rawBody with your JSON library; on
                // payment_intent.succeeded, fulfil the order once.
                System.out.println("verified " + rawBody.length + " bytes");
            }
            exchange.sendResponseHeaders(valid ? 200 : 400, -1);
            exchange.close();
        });
        server.start();
    }
}
```

```csharp title="Program.cs"
// dotnet new web, with WebhookSignature.cs beside this file.
using System.Text.Json;

var app = WebApplication.Create(args);
var secret = Environment.GetEnvironmentVariable("APX_WEBHOOK_SECRET")!;

app.MapPost("/webhooks/payments", async (HttpRequest request) =>
{
    // The exact bytes: verify them before parsing.
    using var buffer = new MemoryStream();
    await request.Body.CopyToAsync(buffer);
    var rawBody = buffer.ToArray();
    var header = request.Headers["X-AdProMaxPay-Signature"].ToString();
    if (!WebhookSignature.Verify(rawBody, header, secret))
        return Results.BadRequest();

    using var json = JsonDocument.Parse(rawBody);
    var root = json.RootElement;
    if (root.GetProperty("type").GetString() == "payment_intent.succeeded")
    {
        var paymentIntent = root.GetProperty("data").GetProperty("object");
        // Fulfil the order that holds this payment intent, once.
        Console.WriteLine($"paid {paymentIntent.GetProperty("id").GetString()}");
    }
    return Results.Ok();
});

app.Run("http://localhost:4242");
```

```rust title="main.rs"
// Cargo.toml: axum = "0.8", tokio = { version = "1", features = ["full"] },
//             serde_json = "1", hmac = "0.12", sha2 = "0.10", hex = "0.4"
mod signature; // signature.rs, from "Verifying signatures"

use std::time::{SystemTime, UNIX_EPOCH};

use axum::body::Bytes;
use axum::http::{HeaderMap, StatusCode};
use axum::routing::post;
use axum::Router;

async fn webhook(headers: HeaderMap, body: Bytes) -> StatusCode {
    // The exact bytes: verify them before parsing.
    let header = headers
        .get("x-adpromaxpay-signature")
        .and_then(|value| value.to_str().ok())
        .unwrap_or("");
    let secret = std::env::var("APX_WEBHOOK_SECRET").unwrap_or_default();
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_or(0, |elapsed| elapsed.as_secs() as i64);
    if !signature::verify_signature(&body, header, &secret, now) {
        return StatusCode::BAD_REQUEST;
    }
    let Ok(event) = serde_json::from_slice::<serde_json::Value>(&body) else {
        return StatusCode::BAD_REQUEST;
    };
    if event["type"] == "payment_intent.succeeded" {
        let id = event["data"]["object"]["id"].as_str().unwrap_or_default();
        // Fulfil the order that holds this payment intent, once.
        println!("paid {id}");
    }
    StatusCode::OK
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/webhooks/payments", post(webhook));
    let listener = tokio::net::TcpListener::bind("0.0.0.0:4242")
        .await
        .expect("port 4242 is free");
    axum::serve(listener, app).await.expect("the server runs");
}
```

## Sans code : un lien de paiement

Un lien de paiement se crée sur le tableau de bord, sous **Liens de paiement → Nouveau** : un titre,
un montant et les moyens de paiement. Partagez-le en lien, en QR code ou en message WhatsApp. Chaque
personne qui l’ouvre obtient la même page hébergée et son propre paiement, et votre webhook reçoit
les mêmes événements.

En mode test, un lien accepte les cartes de test de l’étape 4, et rien d’autre.

## Et ensuite

* [Démarrage rapide : paiement en espèces](https://pay-docs.adpromax.com/fr/get-started/quickstart-cash) : la même session, payée
  avec un code chez un agent de paiement.
* [Le fonctionnement des paiements](https://pay-docs.adpromax.com/fr/payments/how-payments-work) : chaque statut, et que faire dans
  chacun.
* [Bac à sable et production : les différences](https://pay-docs.adpromax.com/fr/sandbox/differences), avant de passer à vos clés
  de production.
