Server-to-Server Integration
Build a custom payment form that submits payments directly through the CatalystPay API, without redirecting to the hosted payment page.
How it works
Prerequisites
- A CatalystPay API key (
rk_...) - A campaign ID from your CatalystPay admin panel (or use direct mode with
gateway_id,amount,currency, andbilling_type) - A backend server that can make HTTPS requests
S2S mode means your server handles the IBAN directly. Ensure your infrastructure meets PCI and data-handling requirements before collecting payment details server-side.
Step 1: Create a payment session
Create a session from your backend when the customer reaches checkout.
import requests
API_KEY = "rk_your_api_key"
BASE_URL = "https://api.catalystpay.com/api/v1"
response = requests.post(
f"{BASE_URL}/payment-sessions/",
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
},
json={
"campaign_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"lead": {
"first_name": "Max",
"last_name": "Mustermann",
"email": "[email protected]",
"country": "DE",
"address": "Musterstrasse 42",
"city": "Berlin",
"postal_code": "10115",
},
},
)
session = response.json()
session_token = session["session_token"]
# Store session_token in your checkout flow (e.g., user session or database)
const API_KEY = "rk_your_api_key";
const BASE_URL = "https://api.catalystpay.com/api/v1";
const response = await fetch(`${BASE_URL}/payment-sessions/`, {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
campaign_id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
lead: {
first_name: "Max",
last_name: "Mustermann",
email: "[email protected]",
country: "DE",
address: "Musterstrasse 42",
city: "Berlin",
postal_code: "10115",
},
}),
});
const session = await response.json();
const sessionToken = session.session_token;
curl -X POST https://api.catalystpay.com/api/v1/payment-sessions/ \
-H "Authorization: Bearer rk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"campaign_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"lead": {
"first_name": "Max",
"last_name": "Mustermann",
"email": "[email protected]",
"country": "DE",
"address": "Musterstrasse 42",
"city": "Berlin",
"postal_code": "10115"
}
}'
Response:
{
"session_token": "ps_abc123def456ghi789jkl012mno345",
"status": "PENDING",
"payment_url": "https://pay.catalystpay.com/s/ps_abc123def456ghi789jkl012mno345"
}
Store the session_token. You do not need the payment_url for S2S — you will call the API directly.
Step 2: Validate the IBAN
Before submitting payment, validate the customer's IBAN to catch errors early and display the bank name.
response = requests.post(
f"{BASE_URL}/iban-validate/",
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
},
json={"iban": "DE89370400440532013000"},
)
result = response.json()
print(result)
# {"valid": true, "bank_name": "Commerzbank", "bic": "COBADEFFXXX"}
const validateRes = await fetch(`${BASE_URL}/iban-validate/`, {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ iban: "DE89370400440532013000" }),
});
const result = await validateRes.json();
console.log(result);
// { valid: true, bank_name: "Commerzbank", bic: "COBADEFFXXX" }
curl -X POST https://api.catalystpay.com/api/v1/iban-validate/ \
-H "Authorization: Bearer rk_your_api_key" \
-H "Content-Type: application/json" \
-d '{"iban": "DE89370400440532013000"}'
If valid is true, show the bank_name to the customer as confirmation. If valid is false, display the error message and ask them to re-enter.
You can also authenticate the IBAN validation endpoint with a session token (Bearer ps_...) instead of an API key. This is useful if your frontend calls the endpoint directly.
Step 3: Submit the payment
Send the IBAN and account holder name to the pay endpoint.
response = requests.post(
f"{BASE_URL}/payment-sessions/{session_token}/pay/",
json={
"iban": "DE89370400440532013000",
"iban_holder_name": "Max Mustermann",
},
)
result = response.json()
print(result)
const payRes = await fetch(
`${BASE_URL}/payment-sessions/${sessionToken}/pay/`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
iban: "DE89370400440532013000",
iban_holder_name: "Max Mustermann",
}),
}
);
const payResult = await payRes.json();
console.log(payResult);
curl -X POST https://api.catalystpay.com/api/v1/payment-sessions/ps_abc123def456ghi789jkl012mno345/pay/ \
-H "Content-Type: application/json" \
-d '{
"iban": "DE89370400440532013000",
"iban_holder_name": "Max Mustermann"
}'
Every response shares the same envelope — branch on the top-level status. The legacy flat fields (order_number, error, verification_url, retry_allowed) remain for backward compatibility; the nested blocks give you first-tier-platform-grade detail synchronously.
Payment approved:
{
"object": "payment_session.result",
"session_token": "ps_abc123def456ghi789jkl012mno345",
"status": "completed",
"livemode": true,
"created_at": "2026-04-11T07:03:51.851544Z",
"processed_at": "2026-04-11T07:03:51.853191Z",
"order_number": "ORD-A61FBBB6",
"transaction": {
"id": "8f0124b5-208a-4d65-b5c7-21e587324fe0",
"merchant_transaction_id": "TXN-20260411-5E3C26D7",
"gateway_reference": "79608d6d96ca6e3722d4f949d333963c",
"tenant_reference_id": "merchant-order-42",
"status": "approved",
"type": "sdd_sale"
},
"order": {
"id": "534a9677-e668-4a7b-b215-ccf4082f2580",
"order_number": "ORD-A61FBBB6",
"status": "COMPLETED"
},
"subscription": {
"id": "dcf6008a-7ecc-401b-9708-beabe3ae02a4",
"status": "ACTIVE",
"billing_frequency": "MONTHLY",
"next_billing_date": "2026-05-11"
},
"lead": { "id": "a30a52ef-d59f-4018-9192-987ae2e9d85c" },
"amount": "99.00",
"currency": "EUR",
"payment_method": {
"type": "sepa_debit",
"iban_last4": "1182",
"iban_country": "DE",
"bic": "CMCIDEDDXXX",
"bank_name": "Targobank",
"holder_name": "Max Mustermann"
},
"sepa": {
"mandate_reference": "MAND-ORD-A61FBBB6",
"sequence_type": "FRST",
"creditor_identifier": null
},
"adapter": { "name": "Emerchantpay Genesis" },
"payment_gateway": {
"id": "5c5834d1-04be-4b46-83a7-7af603e4e691",
"name": "Lunero EOOD-wpcacare.com-SDD-EUR",
"descriptor": "wpcacare.com",
"test_mode": false
},
"error_details": null
}
Show a success page. subscription is null for one-off purchases.
Verification required (some gateways):
Same envelope — status is awaiting_verification and verification_url is populated. Redirect the customer to the verification_url. Continue to Step 4.
Payment declined:
{
"object": "payment_session.result",
"status": "declined",
"error": "Insufficient funds",
"retry_allowed": true,
"error_details": {
"code": "insufficient_funds",
"message": "Insufficient funds",
"gateway_code": "AM04",
"gateway_message": "Insufficient funds"
},
"transaction": { "status": "declined", "...": "..." },
"order": { "status": "FAILED", "...": "..." },
"...": "...all other blocks identical to approved..."
}
Show error_details.message. If retry_allowed is true, let the customer try again with a different IBAN (go back to Step 2). If retry_allowed is false, the response status is "failed" and no more attempts are allowed.
Understanding the response
| Field | Meaning |
|---|---|
status |
completed, awaiting_verification, declined, or failed. Branch on this first. |
livemode |
true in production, false in staging/test environments. |
transaction.id |
CatalystPay's internal transaction UUID. Use in the admin panel and for support. |
transaction.gateway_reference |
Gateway-assigned tracking ID. null until the gateway returns it (batch adapters populate it at settlement). |
transaction.tenant_reference_id |
Echo of the reconciliation reference you supplied (see below). null if you didn't send one. |
subscription |
Populated only for recurring purchases. null for one-off. |
payment_method.bank_name |
Derived from IBAN via the SEPA bank registry. null for smaller banks not in the registry — do not block on this field. |
sepa.sequence_type |
FRST for first/one-off collections, RCUR for recurring rebills. |
payment_gateway.test_mode |
true when the MID is configured against the gateway's sandbox endpoint. Independent of livemode. |
error_details.code |
Normalized enum: insufficient_funds, invalid_iban, mandate_rejected, account_closed, do_not_honor, gateway_unavailable, or unknown. Prefer this over string-matching error. |
error_details.gateway_code |
Raw adapter code (e.g., AM04) preserved for debugging. |
All documented keys are present on every response — an absent source is emitted as null, not omitted. Rely on the shape being stable.
Optional: tag the transaction with your own reference
Pass a tenant_reference_id in the /pay/ request body to tag the transaction with your own reconciliation reference. The value is echoed back in this endpoint's response, the Reconciliation API (transactions and chargebacks), and outbound webhooks (transaction.created, transaction.status_changed, chargeback.created, payment_session.completed). This lets you match CatalystPay transactions to your internal order IDs without maintaining a lookup table.
{
"iban": "DE89370400440532013000",
"iban_holder_name": "Max Mustermann",
"tenant_reference_id": "merchant-order-42"
}
Rules:
- The value must be unique per tenant. Reusing a value returns
409 Conflictwith"detail": "tenant_reference_id already in use for this tenant". The session remains inPENDINGand can be retried with a different reference. - Max length is 255 characters. Blank strings are rejected with
400. - Omit the field entirely (or pass
null) to create a transaction without a reference. - The value is stored internally and is not forwarded to the payment gateway.
Step 4: Handle verification (if required)
If Step 3 returned awaiting_verification, the customer must complete mandate verification. Redirect them to the verification_url from the response.
After the customer completes verification, they return to your site. Call the verify endpoint to finalize:
response = requests.post(
f"{BASE_URL}/payment-sessions/{session_token}/verify/",
json={
"verification_id": "vnd_verify_abc123", # from the return URL, if available
},
)
result = response.json()
print(result)
# {"session_token": "ps_...", "status": "completed", ...}
const verifyRes = await fetch(
`${BASE_URL}/payment-sessions/${sessionToken}/verify/`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
verification_id: "vnd_verify_abc123",
}),
}
);
const verifyResult = await verifyRes.json();
console.log(verifyResult);
curl -X POST https://api.catalystpay.com/api/v1/payment-sessions/ps_abc123def456ghi789jkl012mno345/verify/ \
-H "Content-Type: application/json" \
-d '{"verification_id": "vnd_verify_abc123"}'
A successful verification returns "status": "completed".
The verification_id parameter is optional. If the return URL from the gateway includes a verification ID, pass it through. Otherwise, send an empty body or omit the field.
Step 5: Receive the webhook
CatalystPay sends a payment_session.completed webhook to your configured endpoint when the payment succeeds. Use this as the authoritative signal to fulfill the order.
{
"session_token": "ps_abc123def456ghi789jkl012mno345",
"status": "COMPLETED",
"order_number": "ORD-a1b2c3d4",
"amount": "29.99",
"currency": "EUR",
"email": "[email protected]"
}
Always verify the X-CatalystPay-Signature header before processing. See the Webhooks guide for signature verification details.
What you learned
- S2S mode gives you full control over the payment UI
- Validate IBANs with
/iban-validate/before submitting payment - The
/pay/endpoint returns one of four outcomes:completed,awaiting_verification,declined(withretry_allowed: true), orfailed(terminal,retry_allowed: false) - Some gateways require a verification step — redirect the customer, then call
/verify/ - Branch on
error_details.code(stable enum) for decline handling, not the legacyerrorstring - Webhooks are the authoritative payment confirmation
Next steps
- Hosted Payment Page Integration — Use the HPP instead for a simpler integration
- Sandbox Testing — Test all outcomes in sandbox mode
- Adapters — Understand why verification is needed and how different gateways behave