Submit a Payment
Submit a SEPA Direct Debit payment for an existing session.
curl -X POST "https://api.catalystpay.com/api/v1/payment-sessions/ps_abc123def456/pay/" \
-H "Content-Type: application/json" \
-d '{
"iban": "DE89370400440532013000",
"iban_holder_name": "Max Mustermann"
}'
import requests
response = requests.post(
f"https://api.catalystpay.com/api/v1/payment-sessions/{token}/pay/",
json={
"iban": "DE89370400440532013000",
"iban_holder_name": "Max Mustermann",
},
)
result = response.json()
if result["status"] == "completed":
print(f"Payment approved. Transaction: {result['transaction']['id']}")
elif result["status"] == "awaiting_verification":
print(f"Redirect customer to: {result['verification_url']}")
elif result["status"] == "declined":
# Branch on the normalized code, not the human-readable message
code = result["error_details"]["code"]
if result["retry_allowed"]:
print(f"Declined ({code}) — customer can retry")
else:
print(f"Declined ({code}) — no retries remaining")
const response = await fetch(
`https://api.catalystpay.com/api/v1/payment-sessions/${token}/pay/`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
iban: 'DE89370400440532013000',
iban_holder_name: 'Max Mustermann',
}),
}
);
const result = await response.json();
if (result.status === 'completed') {
console.log(`Payment approved. Transaction: ${result.transaction.id}`);
} else if (result.status === 'awaiting_verification') {
window.location.href = result.verification_url;
} else if (result.status === 'declined') {
const code = result.error_details.code;
if (result.retry_allowed) {
console.log(`Declined (${code}) — customer can retry`);
} else {
console.log(`Declined (${code}) — no retries remaining`);
}
}
Every response shares the same envelope regardless of outcome. Branch on the top-level status. The legacy flat fields (order_number, verification_url, error, retry_allowed) remain at the top level for backward compatibility; the nested blocks (transaction, order, subscription, payment_method, sepa, adapter, payment_gateway, error_details) give you first-tier-platform-grade detail synchronously.
Response (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",
"verification_url": null,
"error": null,
"retry_allowed": null,
"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": "payments-merchant-SDD-EUR",
"descriptor": "merchant.com",
"test_mode": false
},
"error_details": null
}
subscription is null for one-off purchases.
Response (verification required):
Same envelope as approved, but status is awaiting_verification and verification_url is populated. Redirect the customer to the URL, then call /verify/ when they return.
{
"object": "payment_session.result",
"status": "awaiting_verification",
"verification_url": "https://secure.vend-o.com/v/verify?id=abc123",
"transaction": { "status": "pending", "...": "..." },
"order": { "status": "PENDING", "...": "..." },
"error_details": null
}
Response (declined):
Same envelope, with a populated error_details object. Branch on error_details.code (a stable enum) rather than string-matching error. If retry_allowed is true, the session is back in PENDING and the customer can try again; if false, the session is terminal FAILED.
{
"object": "payment_session.result",
"status": "declined",
"error": "Insufficient funds",
"retry_allowed": true,
"transaction": { "status": "declined", "...": "..." },
"order": { "status": "FAILED", "...": "..." },
"error_details": {
"code": "insufficient_funds",
"message": "Insufficient funds",
"gateway_code": "AM04",
"gateway_message": "Insufficient funds"
}
}
error_details.code is one of: insufficient_funds, invalid_iban, mandate_rejected, account_closed, do_not_honor, gateway_unavailable, unknown. Unknown gateway errors surface as unknown with the raw message preserved in error_details.gateway_message.
All documented keys are present on every response — absent sources are emitted as null rather than omitted, so the shape is stable.
Optional: tag with your own reference
Pass tenant_reference_id in the request body to tag the transaction with your own reconciliation reference. The value is echoed back at transaction.tenant_reference_id in this endpoint's response, and also surfaces in the Reconciliation API and outbound webhooks, so you can match CatalystPay transactions to your internal IDs without maintaining a lookup table.
{
"iban": "DE89370400440532013000",
"iban_holder_name": "Max Mustermann",
"tenant_reference_id": "merchant-order-42"
}
Must be unique per tenant. If the same value is already in use on another transaction, the endpoint returns 409 Conflict with "detail": "tenant_reference_id already in use for this tenant". The session remains retryable on conflict. Max length is 255 characters; blank strings are rejected with 400. The value is stored internally and is not forwarded to the payment gateway.
This is a public endpoint (no API key needed) since the hosted payment page calls it directly. The session token in the URL serves as implicit authentication. If the gateway requires mandate verification, follow the Handle Mandate Verification recipe.