# 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.

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](https://pay-docs.adpromax.com/fr/webhooks/signatures).
  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.

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

```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: ['*'],
  }),
})
const endpoint = await response.json()
// 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: ['*'],
  }),
})
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": ["*"],
    },
)
endpoint = response.json()
response.raise_for_status()
# 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' => ['*'],
    ]),
    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;
```

```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: ['*']
)

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{"*"},
	})
	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": ["*"]
            }
            """;
        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[] { "*" },
    });
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": ["*"]
        }))
        .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](https://pay-docs.adpromax.com/fr/webhooks/signatures) à 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](https://pay-docs.adpromax.com/fr/webhooks/retries). Faites le travail
lent (e-mails, factures) après avoir répondu.

```json title="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énement                        | Quand                                                                                          |
| -------------------------------- | ---------------------------------------------------------------------------------------------- |
| `payment_intent.created`         | Une intention de paiement a été créée.                                                         |
| `payment_intent.requires_action` | Une page de paiement par carte a été ouverte ou un code espèces émis.                          |
| `payment_intent.processing`      | Votre client a payé ; la confirmation est en route.                                            |
| `payment_intent.succeeded`       | Le paiement est confirmé : livrez.                                                             |
| `payment_intent.payment_failed`  | Une tentative a échoué : refusée, expirée ou rejetée.                                          |
| `payment_intent.canceled`        | L’intention de paiement a été annulée.                                                         |
| `charge.succeeded`               | Une tentative a réussi.                                                                        |
| `charge.failed`                  | Une tentative a échoué.                                                                        |
| `charge.refunded`                | Une tentative a été remboursée, en totalité ou en partie.                                      |
| `checkout.session.completed`     | Une session de paiement a été payée.                                                           |
| `checkout.session.expired`       | Une session de paiement s’est terminée sans paiement.                                          |
| `refund.created`                 | Un remboursement a été créé.                                                                   |
| `refund.updated`                 | Un remboursement a changé de statut, par exemple vers `succeeded`.                             |
| `refund.failed`                  | Un remboursement a échoué.                                                                     |
| `review.opened`                  | Quelque chose ne colle pas (autre montant, argent arrivé après la fin) ; rien n’a été crédité. |
| `review.closed`                  | Un 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](https://pay-docs.adpromax.com/fr/sandbox/test-cards) ou un
  [paiement en espèces simulé](https://pay-docs.adpromax.com/fr/sandbox/cash-scenarios), envoie les vrais événements.
* Pour essayer votre gestionnaire seul, envoyez-lui vous-même une requête signée :
  [Vérifier les signatures](https://pay-docs.adpromax.com/fr/webhooks/signatures#tester-votre-gestionnaire) montre comment.
