# Quickstart: card payments with hosted checkout

Create a checkout session, pay it with a test card, and learn the result from a webhook. About ten minutes, in test mode.

Your server creates a **checkout session**, your customer pays on the hosted payment page, and your
server learns the result from a **webhook**. Everything here runs in test mode: no real money moves,
and the card page is a simulation marked TEST.

You need a sandbox account with a verified email. [Create one](https://pay.adpromax.com/auth/signup), then
create your test keys.

## 1. Set your test key

Your test secret key starts with `apx_sk_test_`. You create it on the dashboard, under
**Developers → API keys**, and it is shown once, when it is created. Put it in an environment
variable on the machine that runs the examples.

The samples on this page read it from `APX_SECRET_KEY`. To check the key first, ask the API who you
are: see [Your API keys](https://pay-docs.adpromax.com/en/get-started/api-keys).

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

## 2. Create a checkout session

When your customer clicks *Pay*, your server creates a checkout session with:

* `amount`, in centimes: `25000` is 250.00 MAD;
* `currency`: always `mad`;
* `payment_method_types`: `["card"]` here; add `"cash"` to let your customer choose;
* `success_url` and `cancel_url`: where the hosted page sends your customer back;
* `metadata`: your own references, such as the order number.

The `Idempotency-Key` header is required. If the request fails on the network, send it again with
the same key: you get the same session, never a second one. See [Idempotency](https://pay-docs.adpromax.com/en/api-reference/idempotency).

The answer holds the session's `url` and its `payment_intent`. Store the payment intent's ID with your
order: the webhook in step 6 names it.

```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. Send your customer to the page

Redirect your customer's browser to the session's `url`, with an HTTP `303`. Keep the whole address,
including the part after `#`: it is your customer's access to this session.

The hosted page shows the amount, your business name and the payment methods. A session stays open
for 24 hours unless you set `expires_at` (between 30 minutes and 24 hours).

```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. Pay with a test card

Open the session's `url` in a browser and choose card. The payment page of test mode is a simulation
marked TEST; type this card number on it. If it asks for an expiry date or a security code, use any
future date and any three digits.

After the payment, the page sends you back to your `success_url`.

Other numbers decline, ask for 3-D Secure or answer slowly: see [Test cards](https://pay-docs.adpromax.com/en/sandbox/test-cards).
To pay without a browser, in automated tests, use the [test helpers](https://pay-docs.adpromax.com/en/sandbox/test-helpers).

Coming back to `success_url` is not proof of payment: your customer can close the tab before
coming back, or open that address by hand. Fulfil the order on the webhook of the next steps, or
after reading the payment intent's `status`.

- `4242 4242 4242 4242` (Success): Succeeds at once.

## 5. Create a webhook endpoint

When the payment succeeds, AdProMaxPay sends the event `payment_intent.succeeded` to your webhook
endpoint. Create the endpoint once, for test mode, on the dashboard (**Developers → Webhooks**) or
with the API, as here. The answer shows the endpoint's signing secret (`apx_whsec_…`) this once:
store it in `APX_WEBHOOK_SECRET`.

The address must be public and use `https`. To receive events on your own computer, expose it with
an HTTPS tunnel and use the tunnel's address. Replace the sample's `shop.example.com` with your own
host: an address whose host does not resolve is refused with `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. Handle the event

Your handler checks the signature on the raw body, then fulfils the order that holds the payment
intent's ID. The `verifySignature` function comes from [Verifying signatures](https://pay-docs.adpromax.com/en/webhooks/signatures).

Answer `200` quickly, then do the slow work. An event can arrive more than once: see
[Retries, ordering and deduplication](https://pay-docs.adpromax.com/en/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");
}
```

## No code: a payment link

A payment link is made on the dashboard, under **Payment links → New**: a title, an amount and the
payment methods. Share it as a link, a QR code or a WhatsApp message. Each person who opens it gets
the same hosted page and a payment of their own, and your webhook receives the same events.

In test mode, a link takes the test cards of step 4 and nothing else.

## Next steps

* [Quickstart: cash payments](https://pay-docs.adpromax.com/en/get-started/quickstart-cash): the same session, paid with a code at a
  payment agent.
* [How payments work](https://pay-docs.adpromax.com/en/payments/how-payments-work): every status, and what to do in each.
* [Sandbox and live: the differences](https://pay-docs.adpromax.com/en/sandbox/differences), before you switch to your live keys.
