Webhooks

Les webhooks en bref

Comment AdProMaxPay informe votre serveur de ce qui s’est passé, comment configurer un endpoint, et les événements que vous pouvez recevoir.

Ouvrir en Markdown
Sur cette page
  1. Configurer un endpoint
  2. À quoi ressemble une livraison
  3. Types d’événements
  4. Tester les webhooks

Un webhook est une requête HTTPS que AdProMaxPay envoie à votre serveur quand quelque chose se passe : un paiement réussit, un code expire, un remboursement aboutit. Votre serveur répond 2xx, et la livraison est faite. Les webhooks vous apprennent l’issue d’un paiement que votre client a fait ailleurs, sur la page hébergée ou chez un agent de paiement.

Configurer un endpoint

Un endpoint est une adresse sur votre serveur, avec les événements qu’elle veut recevoir. Créez-le sur le tableau de bord, sous Développeurs → Webhooks, ou avec l’API.

  • L’adresse doit être en https, sans nom d’utilisateur ni mot de passe, et son hôte doit se résoudre en adresses publiques. Une adresse privée ou locale, ou un nom d’hôte qui ne se résout pas, est refusé avec url_not_allowed. La même vérification est refaite avant chaque livraison. Pour tester sur votre propre ordinateur, utilisez un tunnel HTTPS. Dans les exemples, shop.example.com tient la place de votre propre hôte : tel quel il ne se résout pas, et il est refusé.
  • enabled_events liste les types d’événements voulus, ou ["*"] pour tous.
  • Le test et la production sont séparés : un endpoint créé avec une clé de test ne reçoit que les événements de test. Créez votre endpoint de production au passage en production.
  • La réponse porte le secret de signature de l’endpoint, apx_whsec_…, cette seule fois. Rangez-le avec vos autres secrets : il vous sert à vérifier chaque livraison. Sur le tableau de bord, vous pouvez l’afficher de nouveau ou le renouveler.
  • Une organisation garde au plus 16 endpoints par mode. Un de plus est refusé avec webhook_endpoint_limit_reached : supprimez d’abord un endpoint qui ne sert plus.

Modifier, suspendre ou supprimer un endpoint

Envoyez les champs qui changent à POST /v1/webhook_endpoints/{id} ; les autres restent tels quels.

  • metadata est fusionné avec ce que l’endpoint contient : les clés envoyées sont posées, les autres restent, et une clé envoyée avec une valeur vide ("") est retirée.
  • "disabled": true suspend l’endpoint : aucune nouvelle livraison ne lui est faite, et celles qui attendaient s’arrêtent. "disabled": false le réactive.
  • DELETE /v1/webhook_endpoints/{id} le supprime définitivement. Ses livraisons restent listées.
  • curl
  • Node.js
  • TypeScript
  • Python
  • PHP
  • Ruby
  • Go
  • Java
  • C#
  • Rust
endpoint.mjsendpoint.tsendpoint.pyendpoint.phpendpoint.rbendpoint.goEndpoint.javaEndpoint.csmain.rs
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": ["*"]
  }'
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: ['*'],
  }),
})
const endpoint = await response.json()
// The secret is shown this once: store it.
console.log(endpoint.id, endpoint.secret)
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: ['*'],
  }),
})
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)
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": ["*"],
    },
)
endpoint = response.json()
response.raise_for_status()
# The secret is shown this once: store it.
print(endpoint["id"], endpoint["secret"])
<?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' => ['*'],
    ]),
    CURLOPT_RETURNTRANSFER => true,
]);
$endpoint = json_decode(curl_exec($ch), true);
// The secret is shown this once: store it.
echo $endpoint['id'], ' ', $endpoint['secret'], PHP_EOL;
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: ['*']
)

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']}"
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{"*"},
	})
	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)
}
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": ["*"]
            }
            """;
        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());
    }
}
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[] { "*" },
    });
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);
// 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": ["*"]
        }))
        .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(())
}

À quoi ressemble une livraison

Chaque livraison est un POST avec un corps JSON, l’événement, et ces en-têtes :

  • Content-Type: application/json ;
  • X-AdProMaxPay-Signature : la signature à vérifier avant de croire le corps.

L’événement nomme son type et porte, dans data.object, l’objet tel qu’il était au moment de l’événement : ici, l’intention de paiement qui a réussi. Servez-vous de id pour reconnaître un événement déjà traité. request nomme la requête d’API qui a causé l’événement ; il est vide quand c’est le paiement de votre client qui l’a causé, comme ici.

Répondez par n’importe quel statut 2xx dans les 10 secondes. Tout le reste, redirection comprise, compte comme un échec et la livraison est retentée. Faites le travail lent (e-mails, factures) après avoir répondu.

payment_intent.succeeded
{
  "id": "evt_01k7d6b4c9x3z7v2n5m8q1w4ry",
  "object": "event",
  "type": "payment_intent.succeeded",
  "api_version": "2026-11-01",
  "created": 1790851200,
  "livemode": false,
  "organization": "org_01k7cz9q3m8v6t2x4b5n7p1r0s",
  "data": {
    "object": {
      "id": "pi_01k7d2h6w0f3r8t5y2v9n4p6qa",
      "object": "payment_intent",
      "amount": 25000,
      "amount_received": 25000,
      "currency": "mad",
      "status": "succeeded",
      "latest_charge": "ch_01k7d2j8m3n6b9v2c5x8z1a4qd",
      "metadata": {},
      "livemode": false
    }
  },
  "request": { "id": null, "idempotency_key": null },
  "pending_webhooks": 1
}

Types d’événements

ÉvénementQuand
payment_intent.createdUne intention de paiement a été créée.
payment_intent.requires_actionUne page de paiement par carte a été ouverte ou un code espèces émis.
payment_intent.processingVotre client a payé ; la confirmation est en route.
payment_intent.succeededLe paiement est confirmé : livrez.
payment_intent.payment_failedUne tentative a échoué : refusée, expirée ou rejetée.
payment_intent.canceledL’intention de paiement a été annulée.
charge.succeededUne tentative a réussi.
charge.failedUne tentative a échoué.
charge.refundedUne tentative a été remboursée, en totalité ou en partie.
checkout.session.completedUne session de paiement a été payée.
checkout.session.expiredUne session de paiement s’est terminée sans paiement.
refund.createdUn remboursement a été créé.
refund.updatedUn remboursement a changé de statut, par exemple vers succeeded.
refund.failedUn remboursement a échoué.
review.openedQuelque chose ne colle pas (autre montant, argent arrivé après la fin) ; rien n’a été crédité.
review.closedUn examen a été tranché.

Le catalogue contient aussi payout.created, payout.paid, payout.failed, balance.available et account.updated. Un endpoint peut s’y abonner dès aujourd’hui ; ils seront envoyés quand les versements et les soldes arriveront dans l’API.

Tester les webhooks

  • Envoyer un événement de test, sur la page d’un endpoint du tableau de bord (mode test), livre un événement d’exemple pour vérifier que votre adresse répond.
  • Chaque livraison et chacune de ses tentatives sont listées sur le tableau de bord, avec le code de statut, la durée et le corps de votre réponse. Renvoyer la remet en file : elle part en quelques secondes. Un endpoint désactivé doit d’abord être réactivé.
  • Un vrai paiement de test, payé avec une carte de test ou un paiement en espèces simulé, envoie les vrais événements.
  • Pour essayer votre gestionnaire seul, envoyez-lui vous-même une requête signée : Vérifier les signatures montre comment.