Payments

Metadata

Attach your own references to payments, sessions, customers and refunds, and find them again in webhooks and on the dashboard.

Open as Markdown
On this page
  1. Add metadata to a payment
  2. The limits
  3. Objects that take metadata
  4. Metadata stays where you put it
  5. Where you see it
  6. What never goes in metadata

Metadata is a set of your own keys and values on an object: an order number, a cart ID, the shop or the channel a sale came from. AdProMaxPay stores it and gives it back, unchanged, wherever the object appears. It never changes how a payment works.

Add metadata to a payment

Send metadata as an object of strings when you create the payment intent. Your references then come back in the API's answers and in every event about this payment: when your webhook receives payment_intent.succeeded, data.object.metadata.order_id names the order to fulfil.

  • curl
  • Node.js
  • TypeScript
  • Python
  • PHP
  • Ruby
  • Go
  • Java
  • C#
  • Rust
metadata.mjsmetadata.tsmetadata.pymetadata.phpmetadata.rbmetadata.goMetadata.javaMetadata.csmain.rs
curl https://pay-api.adpromax.com/v1/payment_intents \
  -u "$APX_SECRET_KEY:" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1045-create" \
  -d '{
    "amount": 25000,
    "currency": "mad",
    "payment_method_types": ["card", "cash"],
    "metadata": { "order_id": "1045", "channel": "whatsapp" }
  }'
const response = await fetch('https://pay-api.adpromax.com/v1/payment_intents', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.APX_SECRET_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': 'order-1045-create',
  },
  body: JSON.stringify({
    amount: 25000,
    currency: 'mad',
    payment_method_types: ['card', 'cash'],
    metadata: { order_id: '1045', channel: 'whatsapp' },
  }),
})
const intent = await response.json()
if (!response.ok)
  throw new Error(intent.error.message)
console.log(intent.id, intent.metadata.order_id)
interface PaymentIntent {
  id: string
  object: 'payment_intent'
  amount: number
  currency: string
  status: 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/payment_intents', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.APX_SECRET_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': 'order-1045-create',
  },
  body: JSON.stringify({
    amount: 25000,
    currency: 'mad',
    payment_method_types: ['card', 'cash'],
    metadata: { order_id: '1045', channel: 'whatsapp' },
  }),
})
if (!response.ok) {
  const { error } = (await response.json()) as ApiError
  throw new Error(`${error.code}: ${error.message}`)
}
const intent = (await response.json()) as PaymentIntent
console.log(intent.id, intent.metadata.order_id)
import os

import requests

response = requests.post(
    "https://pay-api.adpromax.com/v1/payment_intents",
    auth=(os.environ["APX_SECRET_KEY"], ""),
    headers={"Idempotency-Key": "order-1045-create"},
    json={
        "amount": 25000,
        "currency": "mad",
        "payment_method_types": ["card", "cash"],
        "metadata": {"order_id": "1045", "channel": "whatsapp"},
    },
)
intent = response.json()
response.raise_for_status()
print(intent["id"], intent["metadata"]["order_id"])
<?php
$ch = curl_init('https://pay-api.adpromax.com/v1/payment_intents');
curl_setopt_array($ch, [
    CURLOPT_USERPWD => getenv('APX_SECRET_KEY') . ':',
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        'Content-Type: application/json',
        'Idempotency-Key: order-1045-create',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'amount' => 25000,
        'currency' => 'mad',
        'payment_method_types' => ['card', 'cash'],
        'metadata' => ['order_id' => '1045', 'channel' => 'whatsapp'],
    ]),
    CURLOPT_RETURNTRANSFER => true,
]);
$intent = json_decode(curl_exec($ch), true);
echo $intent['id'], ' ', $intent['metadata']['order_id'], PHP_EOL;
require 'json'
require 'net/http'

uri = URI('https://pay-api.adpromax.com/v1/payment_intents')
request = Net::HTTP::Post.new(uri)
request.basic_auth(ENV.fetch('APX_SECRET_KEY'), '')
request['Content-Type'] = 'application/json'
request['Idempotency-Key'] = 'order-1045-create'
request.body = JSON.generate(
  amount: 25000,
  currency: 'mad',
  payment_method_types: %w[card cash],
  metadata: { order_id: '1045', channel: 'whatsapp' }
)

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
  http.request(request)
end
intent = JSON.parse(response.body)
raise intent.dig('error', 'message') unless response.is_a?(Net::HTTPSuccess)

puts "#{intent['id']} #{intent['metadata']['order_id']}"
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"os"
)

type PaymentIntent struct {
	ID       string            `json:"id"`
	Status   string            `json:"status"`
	Metadata map[string]string `json:"metadata"`
}

type APIError struct {
	Error struct {
		Code    string `json:"code"`
		Message string `json:"message"`
	} `json:"error"`
}

func main() {
	metadata := map[string]string{"order_id": "1045", "channel": "whatsapp"}
	body, err := json.Marshal(map[string]any{
		"amount":               25000,
		"currency":             "mad",
		"payment_method_types": []string{"card", "cash"},
		"metadata":             metadata,
	})
	if err != nil {
		log.Fatal(err)
	}
	req, err := http.NewRequest(http.MethodPost,
		"https://pay-api.adpromax.com/v1/payment_intents", 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", "order-1045-create")

	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 intent PaymentIntent
	if err := json.NewDecoder(res.Body).Decode(&intent); err != nil {
		log.Fatal(err)
	}
	fmt.Println(intent.ID, intent.Metadata["order_id"])
}
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class Metadata {
    public static void main(String[] args) throws Exception {
        // Your own references, as strings.
        String metadata = """
            { "order_id": "1045", "channel": "whatsapp" }""";
        String body = """
            {
              "amount": 25000,
              "currency": "mad",
              "payment_method_types": ["card", "cash"],
              "metadata": %s
            }
            """.formatted(metadata);
        HttpRequest request = HttpRequest.newBuilder(
                URI.create("https://pay-api.adpromax.com/v1/payment_intents"))
            .header("Authorization", "Bearer " + System.getenv("APX_SECRET_KEY"))
            .header("Content-Type", "application/json")
            .header("Idempotency-Key", "order-1045-create")
            .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 payment intent as JSON: its metadata comes back as you sent 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 request = new HttpRequestMessage(
    HttpMethod.Post, "https://pay-api.adpromax.com/v1/payment_intents");
request.Headers.Add("Idempotency-Key", "order-1045-create");
request.Content = JsonContent.Create(new
{
    amount = 25000,
    currency = "mad",
    payment_method_types = new[] { "card", "cash" },
    metadata = new { order_id = "1045", channel = "whatsapp" },
});

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 intent = await response.Content.ReadFromJsonAsync<PaymentIntent>(json);
Console.WriteLine($"{intent!.Id} {intent.Metadata["order_id"]}");

record PaymentIntent(string Id, string Status, Dictionary<string, string> Metadata);
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 std::collections::HashMap;

use serde::Deserialize;
use serde_json::json;

#[derive(Deserialize)]
struct PaymentIntent {
    id: String,
    metadata: HashMap<String, 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/payment_intents")
        .bearer_auth(std::env::var("APX_SECRET_KEY")?)
        .header("Idempotency-Key", "order-1045-create")
        .json(&json!({
            "amount": 25000,
            "currency": "mad",
            "payment_method_types": ["card", "cash"],
            "metadata": { "order_id": "1045", "channel": "whatsapp" }
        }))
        .send()?;
    if !response.status().is_success() {
        let failure: ApiError = response.json()?;
        return Err(failure.error.message.into());
    }
    let intent: PaymentIntent = response.json()?;
    println!("{} {}", intent.id, intent.metadata["order_id"]);
    Ok(())
}

The limits

  • Up to 50 keys on one object.
  • A key is up to 40 characters; a value up to 500 characters.
  • Values are strings only. Send a number as text: "1045", not 1045.

A request beyond these limits is refused with 400, parameter_invalid, and param naming the key at fault, such as metadata.order_id. Nothing is created. Read param to find the key: the message is a general sentence and does not say which limit was passed. In this answer, the key is longer than 40 characters.

Response400 Bad Request
{
  "error": {
    "type": "invalid_request_error",
    "code": "parameter_invalid",
    "param": "metadata.customer_reference_from_our_previous_system",
    "message": "This value is not valid.",
    "doc_url": "https://pay-docs.adpromax.com/errors#parameter_invalid",
    "request_id": "req_01k7e5s3d7f1g5h9j3k7m1n5pz"
  }
}

Objects that take metadata

ObjectSet itChange it
Payment intentPOST /v1/payment_intentsPOST /v1/payment_intents/{id}, while the payment is requires_payment_method
Checkout sessionPOST /v1/checkout/sessions—
CustomerPOST /v1/customersPOST /v1/customers/{id}, at any time
RefundPOST /v1/refunds—
Webhook endpointPOST /v1/webhook_endpointsPOST /v1/webhook_endpoints/{id}

A change to a webhook endpoint's metadata merges with what it holds: the keys you send are set, the others stay, and a key sent with an empty value ("") is removed.

Metadata stays where you put it

Metadata belongs to the object you set it on, and is never copied to another one. A checkout session's metadata is on the session and in its checkout.session.* events; the payment intent the session creates does not carry it.

With hosted checkout, keep the payment intent's ID with your order, as the card quickstart does, or read the session when checkout.session.completed arrives.

Where you see it

  • In the API: every answer that holds the object.
  • In webhooks: the event's data.object carries the object's metadata as it was when the event happened.
  • On the dashboard: a payment's page shows its metadata, read-only, and the payments list can be filtered by metadata. A customer's metadata is edited on the customer's page.

What never goes in metadata

Metadata is for references, not for secrets. Your whole team sees it on the dashboard, and your webhook endpoints receive it.

  • No card numbers. Card numbers never travel through the API: a value that looks like a card number is refused with parameter_invalid.
  • No passwords, keys or tokens.
  • No sensitive personal data, such as identity numbers or health details. To know who paid, use a customer and keep the rest in your own systems, under your ID.