Webhooks
Webhooks overview
How AdProMaxPay tells your server what happened, how to set up an endpoint, and the events you can receive.
A webhook is an HTTPS request that AdProMaxPay sends to your server when something happens: a payment
succeeds, a code expires, a refund completes. Your server answers 2xx, and the delivery is done.
Webhooks are how you learn the outcome of a payment your customer made elsewhere, on the hosted page
or at a payment agent.
Set up an endpoint
An endpoint is an address on your server, with the events it wants. Create it on the dashboard, under Developers → Webhooks, or with the API.
- The address must use
https, carry no user name or password, and name a host that resolves to public addresses. A private or local address, or a host name that does not resolve, is refused withurl_not_allowed. The same check runs again before each delivery. To test on your own computer, use an HTTPS tunnel. In the samples,shop.example.comstands for your own host: as written it does not resolve, and is refused. enabled_eventslists the event types you want, or["*"]for all of them.- Test and live are separate: an endpoint created with a test key receives test events only. Create your live endpoint when you go live.
- The answer carries the endpoint's signing secret,
apx_whsec_…, this once. Store it with your other secrets: you need it to verify each delivery. On the dashboard you can show it again or roll it. - An organization holds at most 16 endpoints per mode. One more is refused with
webhook_endpoint_limit_reached: delete an endpoint you no longer use first.
Change, pause or delete an endpoint
Send the fields that change to POST /v1/webhook_endpoints/{id}; the others stay as they are.
metadatais merged with what the endpoint holds: the keys you send are set, the others stay, and a key sent with an empty value ("") is removed."disabled": truepauses the endpoint: no new delivery is made to it, and the ones waiting stop."disabled": falseenables it again.DELETE /v1/webhook_endpoints/{id}removes it for good. Its deliveries stay listed.
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(())
}What a delivery looks like
Each delivery is a POST with a JSON body, the event, and these headers:
Content-Type: application/json;X-AdProMaxPay-Signature: the signature you check before trusting the body.
The event names its type and carries, in data.object, the object as it was when the event
happened: here, the payment intent that succeeded. Use id to recognise an event you already
handled. request names the API request that caused the event; it is empty when your customer's
payment caused it, as here.
Answer with any 2xx status within 10 seconds. Anything else, a redirect included, counts as a
failure and the delivery is retried. Do slow work (emails, invoices) after
answering.
{
"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
}Event types
| Event | When |
|---|---|
payment_intent.created | A payment intent was created. |
payment_intent.requires_action | A card payment page was opened or a cash code issued. |
payment_intent.processing | Your customer paid; the confirmation is on its way. |
payment_intent.succeeded | The payment is confirmed: deliver. |
payment_intent.payment_failed | An attempt failed: declined, expired or refused. |
payment_intent.canceled | The payment intent was canceled. |
charge.succeeded | An attempt succeeded. |
charge.failed | An attempt failed. |
charge.refunded | An attempt was refunded, in full or in part. |
checkout.session.completed | A checkout session was paid. |
checkout.session.expired | A checkout session ended unpaid. |
refund.created | A refund was created. |
refund.updated | A refund changed status, for example to succeeded. |
refund.failed | A refund failed. |
review.opened | Something does not add up (another amount, money after the end); nothing was credited. |
review.closed | A review was settled. |
The catalogue also holds payout.created, payout.paid, payout.failed, balance.available and
account.updated. An endpoint can subscribe to them today; they are sent once payouts and balances
reach the API.
Testing webhooks
- Send test event, on an endpoint's page of the dashboard (test mode), delivers a sample event to check that your address answers.
- Every delivery and each of its attempts is listed on the dashboard, with the status code, the time taken and your answer's body. Resend queues it again: it leaves within seconds. An endpoint that is disabled must be enabled first.
- A real test payment, paid with a test card or a simulated cash payment, sends the real events.
- To try your handler alone, send it a signed request yourself: Verifying signatures shows how.