Webhooks

Webhooks overview

How AdProMaxPay tells your server what happened, how to set up an endpoint, and the events you can receive.

Open as Markdown
On this page
  1. Set up an endpoint
  2. What a delivery looks like
  3. Event types
  4. Testing webhooks

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 with url_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.com stands for your own host: as written it does not resolve, and is refused.
  • enabled_events lists 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.

  • metadata is 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": true pauses the endpoint: no new delivery is made to it, and the ones waiting stop. "disabled": false enables it again.
  • DELETE /v1/webhook_endpoints/{id} removes it for good. Its deliveries stay listed.
  • 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(())
}

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.

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
}

Event types

EventWhen
payment_intent.createdA payment intent was created.
payment_intent.requires_actionA card payment page was opened or a cash code issued.
payment_intent.processingYour customer paid; the confirmation is on its way.
payment_intent.succeededThe payment is confirmed: deliver.
payment_intent.payment_failedAn attempt failed: declined, expired or refused.
payment_intent.canceledThe payment intent was canceled.
charge.succeededAn attempt succeeded.
charge.failedAn attempt failed.
charge.refundedAn attempt was refunded, in full or in part.
checkout.session.completedA checkout session was paid.
checkout.session.expiredA checkout session ended unpaid.
refund.createdA refund was created.
refund.updatedA refund changed status, for example to succeeded.
refund.failedA refund failed.
review.openedSomething does not add up (another amount, money after the end); nothing was credited.
review.closedA 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.