Dapit Instant Payments API Merchant Documentation

Instant Payments API

Accept instant SEPA payments from your customers with zero chargebacks, no rolling reserves, and same-day settlement. This API lets you create checkout sessions and receive payment confirmations.

Overview

The Dapit Instant Payments API enables merchants to accept EUR payments via SEPA Instant Credit Transfer. Instead of credit cards, your customers pay directly from their bank account (Wise, Revolut, or any EUR-enabled bank). Payments settle in under 10 seconds and are irrevocable — no chargebacks.

Key Benefits:Very low Fees · Zero rolling reserves · Same-day settlement · No chargebacks · No card network dependency · Works worldwide via Wise/Revolut

Base URL:

https://www.maxafi.com/api/gateway/gateway.php

Payment Flow

The customer never leaves your website. When they choose "Pay with Instant Payments" at your checkout, your server talks to our API behind the scenes.

1
Create Session
Your server calls our API with the order amount
2
Display Payment Info
Show the IBAN, beneficiary name, and reference on YOUR checkout page
3
Customer Pays
They open Wise/Revolut, send EUR with the reference number
4
We Verify & Notify (automatic)
Our reconciliation cron matches the credit on the custodial IBAN within ~60 seconds and POSTs to your webhook. No customer action needed.
About the old "I Paid" button. Earlier versions of this guide required the customer to click "I Paid" to trigger payment verification. That step is no longer necessary — our server-side reconciliation cron matches the credit automatically within ~60 seconds of SEPA Instant settlement and fires your webhook regardless of whether the customer clicks anything. The button is still available on the hosted checkout and via the confirm_paid API as an optional "verify now" affordance for impatient customers who don't want to wait the full 60 seconds. If you skip it entirely, the cron handles everything.
Two integration options:
Option A — API Integration (recommended): Your developer embeds the payment flow directly into your existing checkout page. The customer never leaves your site. You control the look and feel.

Option B — Hosted Checkout: If you don't have a developer, we provide a hosted checkout page. You redirect the customer to our URL and we handle everything. They see your merchant name on a Dapit-branded page.

Authentication

All API requests require your merchant API key. Include it in one of the following request headers:

X-Api-Key: mk_your_api_key_here

or, for legacy integrations:

X-Gateway-Key: your_api_key_here

Keys beginning with mk_ are unified keys that also carry permissions, rate limits, and environment (sandbox/production) metadata. Legacy plaintext keys without a prefix remain accepted.

Your API key is provided during merchant onboarding. Keep it secret — it authenticates all requests to our API on your behalf.

Security: Never expose your API key in client-side code. All API calls should be made from your server (backend), not from the browser.
New integrators: Before you build anything else, call Verify Key. It's a zero-cost health check that confirms your key is active, tells you whether you're in sandbox or production, and lists the permissions attached to the key.

Quick Start — Option A: API Integration

Your developer adds Instant Payments as a payment option on your existing checkout page. The customer never leaves your website.

Step 1: Customer selects "Pay with Instant Payments" on your checkout

Your checkout page already has credit card, ACH, etc. Add an "Instant Payments" button alongside them. When they click it, your server creates a session:

// YOUR SERVER — Create a session when customer chooses Instant Payments
const response = await fetch('https://www.maxafi.com/api/gateway/gateway.php?action=create_session', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-Gateway-Key': 'your_api_key_here'
  },
  body: JSON.stringify({
    amount: 49.99,
    currency: 'EUR',
    items: [
      { name: 'Premium Game Credits', qty: 1, price: 49.99, total: 49.99 }
    ],
    callback_url: 'https://yoursite.com/webhooks/dapit'
  })
});

const session = await response.json();
// session.session_token → save this (you'll need it for polling)
// Now call create_invoice to get the payment details...

Step 2: Get payment details and display on YOUR page

// YOUR SERVER — Get the IBAN, beneficiary, and reference number
const invoice = await fetch('https://www.maxafi.com/api/gateway/gateway.php?action=create_invoice', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ token: session.session_token })
}).then(r => r.json());

// Display these on YOUR checkout page:
// invoice.beneficiary_name → "Longemalle Trustees OU"
// invoice.iban             → "LT913740020058585210"
// invoice.reference_number → "DP-260330-XA6HSX"
// invoice.amount           → 49.99
// invoice.invoice_number   → "INV-260330-04821"
// invoice.qr_url           → scannable EPC QR (drop into <img src>)
//
// Tell the customer:
// 1. Scan the QR with your banking app (most EU banks support this) —
//    OR transfer manually if your app is Wise / Revolut / N26 (no EPC QR support)
// 2. Beneficiary: Longemalle Trustees OU
// 3. IBAN: LT913740020058585210
// 4. Reference: DP-260330-XA6HSX (must include EXACTLY)
// 5. Choose "Instant" or "SEPA Instant" on your bank's confirmation screen

Step 3: Receive webhook confirmation (this is all you need)

That's it for the required flow. Our reconciliation cron checks the custodial IBAN every 60 seconds for credits matching your reference number. When it finds the match, it POSTs to your callback_url automatically. You don't have to do anything else. The customer doesn't have to click anything. If they pay, you get the webhook within ~60 seconds of SEPA Instant settlement.

// YOUR SERVER — Webhook endpoint receives POST when payment is confirmed
// Fires for both exact and overpayments (not for partial — only when fully paid)
app.post('/webhooks/dapit', (req, res) => {
  const { event, merchant_order_id, invoice_amount, amount_paid,
          overpaid_amount, refund_amount, refund_fee } = req.body;

  if (event === 'payment.confirmed') {
    // ✅ Payment confirmed! Update your order database.
    console.log(`Order ${merchant_order_id} paid: €${amount_paid} of €${invoice_amount}`);

    if (overpaid_amount > 0) {
      // Customer overpaid — refund of €(overpaid - fee) will be processed
      console.log(`Overpaid by €${overpaid_amount}. Refund: €${refund_amount} (fee: €${refund_fee})`);
    }

    // Mark order as paid, send confirmation email, trigger fulfillment
  }

  res.status(200).json({ received: true });
});

Step 4: Optional — speed up the customer experience

Skip this entire section unless you want sub-60-second confirmation in the customer's UI. Webhook reconciliation in Step 3 already handles every payment automatically. The code below adds a faster client-side polling cycle for impatient customers who don't want to wait the cron interval.

If you want the customer to see "✓ Payment received" in their browser within seconds instead of up to a minute, expose an "I've Sent the Payment" button on your page. When the customer taps it, call confirm_paid to start a tighter polling cycle (every 5-7 seconds instead of every 60), then poll check_payment for status. The webhook still fires either way — this just speeds up what the customer sees in the foreground.

// YOUR PAGE (JavaScript) — OPTIONAL: when customer taps "I've Sent the Payment"

// Tell our API to switch to faster polling
await fetch('https://www.maxafi.com/api/gateway/gateway.php?action=confirm_paid', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ token: sessionToken })
});

// Then poll every 7 seconds until payment is confirmed
const pollInterval = setInterval(async () => {
  const check = await fetch('https://www.maxafi.com/api/gateway/gateway.php?action=check_payment', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ token: sessionToken })
  }).then(r => r.json());

  if (check.status === 'confirmed') {
    clearInterval(pollInterval);
    // 🎉 Full payment confirmed!
    showPaymentSuccess(check.total_paid, check.invoice_amount);

  } else if (check.status === 'partial') {
    clearInterval(pollInterval);
    // ⚡ Partial payment — show receipt + remaining balance
    showPartialPayment(check.total_paid, check.amount_remaining, check.payments);
    // When customer sends remaining: call confirm_paid again, restart polling

  } else if (check.status === 'overpaid') {
    clearInterval(pollInterval);
    // 💰 Overpayment — order confirmed, refund will be processed
    showOverpaid(check.total_paid, check.overpaid_amount, check.refund_net, check.refund_fee);

  } else if (check.status === 'timeout') {
    clearInterval(pollInterval);
    showPaymentRetry();
  }
  // 'pending' — keep polling
}, 7000);

Quick Start — Option B: Hosted Checkout

If you don't have a developer or want the simplest possible integration, use our hosted checkout page. You just create a session and redirect — we handle the entire payment UI.

// YOUR SERVER — Create session and redirect customer to our hosted page
const session = await fetch('https://www.maxafi.com/api/gateway/gateway.php?action=create_session', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-Gateway-Key': 'your_api_key_here'
  },
  body: JSON.stringify({
    amount: 49.99,
    currency: 'EUR',
    items: [{ name: 'Premium Game Credits', qty: 1, price: 49.99, total: 49.99 }],
    callback_url: 'https://yoursite.com/webhooks/dapit'
  })
}).then(r => r.json());

// Redirect customer to our hosted checkout
res.redirect('https://www.maxafi.com' + session.checkout_url);
// Customer sees: your merchant name, invoice, IBAN, reference
// Optional "I've Sent the Payment" button (speeds up verification — not required)
// When confirmed, we POST to your callback_url
Option A vs Option B: Option A gives you full control — the payment form matches your brand and the customer never leaves your site. Option B is faster to set up but sends the customer to a Dapit-branded page. Most merchants with a development team should use Option A.

Verify Key

POST ?action=verify_key

Lightweight credential check. Authenticates your API key and returns the merchant identity, environment, and attached permissions. No session is created, nothing is charged, and nothing is written to the database. Safe to call from setup scripts, health checks, and CI.

This should be the first endpoint you hit when wiring up a new integration. If it succeeds, your key is active and configured correctly, and you can proceed to Create Session.

Headers

NameValue
X-Api-KeyYour merchant API key (mk_...) REQUIRED
Content-Typeapplication/json (body can be empty {})

Request Body

None. An empty JSON object {} is fine.

Response — Valid Key

{
  "success": true,
  "merchant_id": 42,
  "merchant_name": "Acme Co.",
  "environment": "stage",
  "permissions": ["write:transactions", "read:transactions"],
  "key_type": "unified",
  "server_time": "2026-04-21T14:22:18Z"
}

Response — Invalid or Missing Key

{
  "success": false,
  "error": "Invalid API key"
}

HTTP status 401 is returned when the key is missing, invalid, or expired. HTTP 403 indicates the key exists but lacks gateway permissions.

Response Fields

FieldTypeDescription
merchant_idnumberInternal merchant ID associated with this key.
merchant_namestringHuman-readable merchant / business name.
environmentstringstage or prod. Confirms whether this key is for sandbox or live traffic.
permissionsarrayScopes attached to the key. Gateway calls require at least read:transactions or write:transactions.
key_typestringunified for mk_-prefixed keys, legacy for older plaintext keys.
server_timestringCurrent UTC server time. Useful for clock-skew checks.

Example — curl

curl -X POST "https://www.maxafi.com/api/gateway/gateway.php?action=verify_key" \
  -H "X-Api-Key: mk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{}'

Create Session

POST ?action=create_session

Creates a new checkout session. Returns a checkout URL to redirect your customer to.

Headers

NameValue
X-Gateway-KeyYour merchant API key REQUIRED
Content-Typeapplication/json

Request Body

ParameterTypeDescription
amountnumberPayment amount. Must be within the per-currency platform range (see Validation below) REQUIRED
currencystringCurrency code. Must be one of EUR, USD, AUD and must match the currency of your custody account. Default: EUR OPTIONAL
itemsarrayArray of line items: [{name, qty, price, total}] OPTIONAL
order_idstringYour own order/invoice number (e.g. ORD-12345). Returned in webhook so you can match to your system. OPTIONAL
customer_emailstringCustomer's email address OPTIONAL
customer_namestringCustomer's name OPTIONAL
callback_urlstringOptional per-session override. URL we POST to when payment is confirmed for THIS session. Falls back to your merchant default if omitted. Passing a value here does not overwrite your saved default — same session-scoped behavior as Stripe and Square. Useful for routing test sessions to a stage endpoint without touching your production URL. OPTIONAL
expires_minutesnumberSession expiry in minutes. Default: 60 OPTIONAL

Validation rules

Three checks run before a session is created. Failure returns HTTP 400 with a descriptive error message before any session row is inserted or reference number is generated — your retry logic should treat a 400 as a permanent failure for that payload, not a transient one.

  1. Currency whitelist. currency must be one of EUR, USD, AUD (case-insensitive).
    Example error:
    Currency 'JPY' is not supported. Allowed: EUR, USD, AUD.
  2. Currency matches custody account. The merchant's custody account (the IBAN the credit will land on) has a fixed currency — SEPA Instant for example is EUR-only. Requesting a different currency would create a session that can never confirm.
    Example error:
    Currency mismatch — session requested USD but the merchant's custody account is a EUR account (LT...). Cannot create session. Use currency='EUR', or contact platform admin to repoint this merchant to a USD custody account.
  3. Amount within configured range. Each currency has a configured min/max set by the platform admin under BaaS Config → Payment Limits. Amounts outside the range are rejected.
    Example error:
    Amount 945911087 EUR exceeds the EUR maximum of 5000.00. Adjust the amount, or contact platform admin to raise the limit via BaaS Config → Payment Limits.

Response

{
  "success": true,
  "session_id": 42,
  "session_token": "a1b2c3d4e5f6...",
  "checkout_url": "/checkout/pay.html?session=a1b2c3d4e5f6...",
  "expires_at": "2026-03-30 23:30:00",
  "amount": 49.99,
  "currency": "EUR",
  "merchant_order_id": "ORD-12345"
}

Error Responses

HTTPWhen
400amount ≤ 0, currency not in whitelist, currency mismatch with custody account, or amount outside the configured per-currency min/max range. The error field tells you which.
401Missing or invalid API key

Create Invoice

POST ?action=create_invoice

Generates the invoice, reference number, and returns the IBAN + beneficiary details. Call this after create_session to get the payment information to display on your checkout page. This is the key endpoint for Option A integration.

Request Body

ParameterTypeDescription
tokenstringThe session_token from create_session REQUIRED

Response

{
  "success": true,
  "reference_number": "DP-260330-XA6HSX",
  "invoice_number": "INV-260330-04821",
  "amount": 49.99,
  "currency": "EUR",
  "iban": "LT913740020058585210",
  "beneficiary_name": "Longemalle Trustees OU",
  "account_name": "Dapit Financial Infrastructure",
  "bank_name": "Dapit",
  "qr_url": "https://www.maxafi.com/api/gateway/epc_qr.php?ref=DP-260330-XA6HSX&token=..."
}
Display all of these on your checkout page: the beneficiary name, IBAN, and reference number. The customer must enter all three in their banking app exactly as shown.
New in v10.4.6: qr_url returns an absolute URL to a scannable EPC scan-to-pay QR code (SVG). Drop it directly into an <img src> tag — see the Scan-to-Pay QR section below for details.

Scan-to-Pay QR Code

GET /api/gateway/epc_qr.php?ref=...&token=...

Returns a scannable EPC069-12 v002 QR code (SVG image) that any modern EU banking app can read to pre-fill a SEPA Credit Transfer. Customer scans, banking app fills in beneficiary name, IBAN, amount, and reference automatically — customer just confirms and picks "Instant" or "SEPA Instant" on the rail picker.

You don't normally call this endpoint directly. create_invoice and get_session already return the absolute URL in the qr_url field. Just embed it in your checkout page:

// React / JSX
<img src={invoice.qr_url} alt="Scan to pay" width="240" height="240" />

// Plain HTML / template
<img src="{{ invoice.qr_url }}" alt="Scan to pay" />

Query Parameters

ParameterTypeDescription
refstringThe reference_number from create_invoice REQUIRED
tokenstringThe session_token from create_session REQUIRED

Response

Content-Type: image/svg+xml — a scannable QR code, ~320×320 pixels by default. Cached for 30 minutes per session.

EPC Payload Encoded in the QR

BCD                              # service tag (fixed)
002                              # version
1                                # UTF-8
SCT                              # SEPA Credit Transfer
CNUALT21XXX                      # BIC
Longemalle Trustees OU           # beneficiary name
LT913740020058585210             # IBAN
EUR49.99                         # amount
OTHR                             # purpose code
                                 # structured ref (blank — we use unstructured)
DP-260330-XA6HSX                 # the Dapit reference
                                 # B2B info (blank)
About SEPA Instant. The EPC QR format doesn't carry a rail selector — the payer chooses Instant vs. Standard SCT in their banking app at the confirmation screen. Your checkout UI should nudge them: "When confirming, choose Instant or SEPA Instant." Dapit's custodial IBAN is SCT Inst reachable, so once they pick Instant the credit arrives in real time and your webhook fires.
Wise and Revolut don't support EPC QR scanning. Those apps don't parse the EPC format regardless of version. Customers using Wise or Revolut should use manual entry (display the IBAN, beneficiary name, and reference as text alongside the QR). Most traditional EU banks do support EPC QR — Sparkasse, Deutsche Bank, ING, Commerzbank, BNP Paribas, KBC, Belfius, Rabobank, Raiffeisen, Erste, and similar.

Full Example: Custom Checkout with QR

// 1. Create session
const session = await fetch('https://www.maxafi.com/api/gateway/gateway.php?action=create_session', {
    method: 'POST',
    headers: { 'X-Api-Key': 'mk_live_...', 'Content-Type': 'application/json' },
    body: JSON.stringify({ amount: 49.99, currency: 'EUR', items: [...] })
}).then(r => r.json());

// 2. Create invoice — this returns qr_url ready to render
const invoice = await fetch('https://www.maxafi.com/api/gateway/gateway.php?action=create_invoice', {
    method: 'POST',
    headers: { 'X-Api-Key': 'mk_live_...', 'Content-Type': 'application/json' },
    body: JSON.stringify({ token: session.session_token })
}).then(r => r.json());

// 3. Render the QR + manual fallback
document.getElementById('checkout').innerHTML = `
    <h2>Pay €${invoice.amount}</h2>
    <img src="${invoice.qr_url}" alt="Scan with your banking app" />
    <p>Or transfer manually:</p>
    <p><strong>Recipient:</strong> ${invoice.beneficiary_name}</p>
    <p><strong>IBAN:</strong> ${invoice.iban}</p>
    <p><strong>Reference:</strong> ${invoice.reference_number}</p>
`;

Notes

  • qr_url is only present when a session has a reference number (i.e. after create_invoice). On get_session calls before invoicing, the field is null.
  • The URL is publicly accessible (no auth header needed) but cryptographically scoped to the session — the QR endpoint validates ref + token against gateway_sessions before emitting. Leaking a qr_url exposes only the EPC payload, no PII or credentials.
  • Cache the SVG client-side. The endpoint emits Cache-Control: private, max-age=1800 (30 minutes), which is plenty for a typical checkout session.
  • If you want to render the QR with your own brand colors or add a center logo, you can fetch the raw EPC payload by parsing the SVG, or contact us for a future ?format=text mode.

Confirm Paid (optional)

POST ?action=confirm_paid
This endpoint is OPTIONAL. Reconciliation is automatic. Our server-side reconciler checks the custodial IBAN every 60 seconds for credits matching your reference number, regardless of whether confirm_paid was ever called. You only need this endpoint if you want a faster client-side polling experience for your customer (sub-60-second confirmation).

Call this if you want our system to start a faster polling cycle (every 5-7 seconds) right after the customer signals they sent the wire. If you skip it, the cron will still match the credit and fire your webhook within ~60 seconds of settlement. Most integrators don't need this endpoint.

Request Body

ParameterTypeDescription
tokenstringThe session_token REQUIRED

Response

{
  "success": true,
  "status": "polling",
  "reference": "DP-260330-XA6HSX",
  "message": "Payment verification started. We are checking for your transfer."
}

Check Payment (Polling) (optional)

POST ?action=check_payment
Also optional. Use this if you want a real-time UI showing the customer "Checking your payment\u2026". If you skip both confirm_paid and check_payment, you'll receive the payment.confirmed webhook automatically when the cron matches the credit — typically within 60 seconds of SEPA Instant settlement, or up to a business day for SEPA Standard.

Poll this endpoint every 5-7 seconds for an interactive payment-confirmation UX. Handles exact payments, partial payments, and overpayments automatically.

Request Body

ParameterTypeDescription
tokenstringThe session_token REQUIRED

Response — Full Payment

{
  "success": true,
  "status": "confirmed",
  "paid_at": "2026-03-31 10:05:12",
  "invoice_amount": 7.00,
  "total_paid": 7.00,
  "payment_count": 1,
  "payments": [{ "amount": 7.00, "debtor_name": "John Smith", "debtor_iban": "DE89370400440532013000" }],
  "amount_match": true
}

Response — Partial Payment

If the customer sends less than the invoice amount, you get a partial status. Show the receipt and remaining balance, then let them send the rest using the same reference number. Call confirm_paid again to restart polling.

{
  "success": true,
  "status": "partial",
  "invoice_amount": 7.00,
  "total_paid": 3.00,
  "amount_remaining": 4.00,
  "payment_count": 1,
  "payments": [{ "amount": 3.00, "debtor_name": "John Smith" }],
  "message": "We received €3.00 of your €7.00 payment. Remaining balance: €4.00."
}

Response — Overpayment

If the customer sends more than the invoice amount, the order is confirmed but we flag the overpayment. A refund will be processed minus a €1.00 processing fee.

{
  "success": true,
  "status": "overpaid",
  "paid_at": "2026-03-31 10:05:12",
  "invoice_amount": 5.00,
  "total_paid": 8.00,
  "overpaid_amount": 3.00,
  "refund_fee": 1.00,
  "refund_net": 2.00,
  "message": "Payment received! You overpaid by €3.00. A refund of €2.00 will be processed (€3.00 minus €1.00 processing fee)."
}

Response — Still Searching

{
  "success": true,
  "status": "pending",
  "message": "Payment not yet detected. Checking again...",
  "poll_count": 5,
  "max_polls": 60
}

Response — Timeout

{
  "success": true,
  "status": "timeout",
  "message": "Payment not detected after maximum attempts. Please contact support."
}
Partial Payment Flow: When you receive status: "partial", display the receipt and remaining balance to the customer. They send the remaining amount with the same reference number. The reconciler will detect the additional credit automatically (within 60 seconds) and fire payment.confirmed when the full amount is received. If you want a faster UX, call confirm_paid + poll check_payment after the customer indicates they sent the additional payment.

Get Session

GET ?action=get_session&token=SESSION_TOKEN

Retrieve details about a checkout session including its current status.

Parameters

ParameterTypeDescription
tokenstringThe session_token from create_session REQUIRED

Response

{
  "success": true,
  "session_id": 42,
  "merchant_name": "Your Store Name",
  "amount": 49.99,
  "currency": "EUR",
  "status": "confirmed",
  "reference_number": "DP-260330-XA6HSX",
  "invoice_number": "INV-260330-04821",
  "qr_url": "https://www.maxafi.com/api/gateway/epc_qr.php?ref=DP-260330-XA6HSX&token=...",
  "paid_at": "2026-03-30 22:05:12"
}

Payment Status

GET ?action=payment_status&reference=DP-XXXXXX-XXXXXX

Check the payment status for a specific reference number. Requires API key authentication.

Headers

NameValue
X-Gateway-KeyYour merchant API key REQUIRED

Parameters

ParameterTypeDescription
referencestringThe reference number (e.g. DP-260330-XA6HSX) REQUIRED*
session_tokenstringOr use the session token instead REQUIRED*

* Provide either reference OR session_token

Response

{
  "success": true,
  "reference_number": "DP-260330-XA6HSX",
  "invoice_number": "INV-260330-04821",
  "amount": 49.99,
  "currency": "EUR",
  "status": "confirmed",
  "confirmed_at": "2026-03-30 22:05:12"
}

List Transactions

GET ?action=list_transactions

List all gateway transactions for your merchant account. Supports filtering by status and pagination.

Headers

NameValue
X-Gateway-KeyYour merchant API key REQUIRED

Parameters

ParameterTypeDescription
statusstringFilter: pending, polling, confirmed, failed, expired OPTIONAL
limitnumberResults per page (max 200). Default: 50 OPTIONAL
offsetnumberPagination offset. Default: 0 OPTIONAL

Response

{
  "success": true,
  "transactions": [
    {
      "reference_number": "DP-260330-XA6HSX",
      "amount": 49.99,
      "status": "confirmed",
      "confirmed_at": "2026-03-30 22:05:12"
    }
  ],
  "total": 1,
  "limit": 50,
  "offset": 0
}

Webhook: Payment Confirmed

When a payment is confirmed, we send a POST request to your registered callback_url with the payment details.

Where the URL comes from. We resolve the destination in this order for every fire:
  1. Session-level override — the callback_url you passed in create_session for this specific session, if any.
  2. Your merchant default — the saved URL on your merchant settings.

A session-level value only affects that session — it does NOT overwrite your saved default. Same shape as Stripe / Square / Adyen. Set your default in merchant settings (one time), then pass a per-session override only when you need to redirect a particular session somewhere else (e.g. routing test transactions to a stage handler while production sessions go to your live endpoint).

Webhook Payload

Every confirmed-side fire shares one shape. Read event and payment_status first — they tell you what happened.

{
  "event": "payment.confirmed",
  "payment_status": "confirmed",
  "reference_number": "DP-260330-XA6HSX",
  "invoice_number": "INV-260330-04821",
  "merchant_order_id": "ORD-12345",
  "invoice_amount": 7.00,
  "amount_paid": 7.00,
  "amount_remaining": 0,
  "overpaid_amount": 0,
  "refund_amount": 0,
  "refund_fee": 0,
  "currency": "EUR",
  "paid_at": "2026-03-31 10:05:12",
  "session_token": "a1b2c3d4e5f6...",
  "customer_email": "john@example.com",
  "customer_name": "John Smith"
}
Branch on payment_status, not event. Both confirmed and overpaid arrive as event: "payment.confirmed" — only payment_status distinguishes them. Underpaid arrives as event: "payment.partial". Keying on payment_status lets a single handler cover all three outcomes.

payment_status values

payment_statuseventWhat it means & what to do
confirmedpayment.confirmedFull amount received. Fulfil the order.
overpaidpayment.confirmedMore than the invoice arrived. The order is paid — fulfil it. A refund of refund_amount (the overpayment minus a €1 fee) is processed separately.
partialpayment.partialLess than the invoice arrived. Do not fulfil yet. The customer sends the balance under the same reference; you receive a payment.confirmed once it clears.

Partial payment (underpaid)

Invoice €7.00, customer sent €3.00. Note the payment.partial event and the non-zero amount_remaining.

{
  "event": "payment.partial",
  "payment_status": "partial",
  "reference_number": "DP-260330-XA6HSX",
  "invoice_number": "INV-260330-04821",
  "merchant_order_id": "ORD-12345",
  "invoice_amount": 7.00,
  "amount_paid": 3.00,
  "amount_remaining": 4.00,
  "overpaid_amount": 0,
  "refund_amount": 0,
  "refund_fee": 0,
  "currency": "EUR",
  "paid_at": "2026-03-31 10:05:12",
  "session_token": "a1b2c3d4e5f6...",
  "customer_email": "john@example.com",
  "customer_name": "John Smith"
}

Overpayment

Invoice €5.00, customer sent €8.00. Still a payment.confirmed event — payment_status is overpaid, and the refund fields are populated.

{
  "event": "payment.confirmed",
  "payment_status": "overpaid",
  "reference_number": "DP-260330-XA6HSX",
  "invoice_number": "INV-260330-04821",
  "merchant_order_id": "ORD-12345",
  "invoice_amount": 5.00,
  "amount_paid": 8.00,
  "amount_remaining": 0,
  "overpaid_amount": 3.00,
  "refund_amount": 2.00,
  "refund_fee": 1.00,
  "currency": "EUR",
  "paid_at": "2026-03-31 10:05:12",
  "session_token": "a1b2c3d4e5f6...",
  "customer_email": "john@example.com",
  "customer_name": "John Smith"
}
merchant_order_id is YOUR order number that you passed when creating the session. Use it to match the payment confirmation back to the order in your system — no need to track our reference numbers if you don't want to.

Expected Response

Return a 200 status code to acknowledge receipt. If we don't get a 200, we'll flag the notification as undelivered and retry.

Handling the webhook

Verify the signature first (see Webhook Security), then branch on payment_status:

// signature already verified — see Webhook Security
function handle(event, res) {
  switch (event.payment_status) {
    case "confirmed":
    case "overpaid":
      // fully paid; overpaid just means a refund is processed separately
      markOrderPaid(event.merchant_order_id, event.amount_paid);
      break;
    case "partial":
      // underpaid — do NOT fulfil; wait for the rest under the same reference
      recordPartial(event.merchant_order_id, event.amount_paid, event.amount_remaining);
      break;
  }
  res.status(200).json({ received: true });
}
# signature already verified — see Webhook Security
status = event["payment_status"]
if status in ("confirmed", "overpaid"):
    # fully paid; overpaid just means a refund is processed separately
    mark_order_paid(event["merchant_order_id"], event["amount_paid"])
elif status == "partial":
    # underpaid — do NOT fulfil; wait for the rest under the same reference
    record_partial(event["merchant_order_id"], event["amount_paid"], event["amount_remaining"])
return {"received": True}, 200
<?php
// signature already verified — see Webhook Security
switch ($event["payment_status"]) {
    case "confirmed":
    case "overpaid":
        // fully paid; overpaid just means a refund is processed separately
        markOrderPaid($event["merchant_order_id"], $event["amount_paid"]);
        break;
    case "partial":
        // underpaid — do NOT fulfil; wait for the rest under the same reference
        recordPartial($event["merchant_order_id"], $event["amount_paid"], $event["amount_remaining"]);
        break;
}
http_response_code(200);
echo json_encode(["received" => true]);
// signature already verified — see Webhook Security
switch ((String) event.get("payment_status")) {
  case "confirmed":
  case "overpaid":
    // fully paid; overpaid just means a refund is processed separately
    markOrderPaid(event.get("merchant_order_id"), event.get("amount_paid"));
    break;
  case "partial":
    // underpaid — do NOT fulfil; wait for the rest under the same reference
    recordPartial(event.get("merchant_order_id"), event.get("amount_paid"));
    break;
}
return ResponseEntity.ok("{\"received\":true}");
// signature already verified — see Webhook Security
switch event.PaymentStatus {
case "confirmed", "overpaid":
    // fully paid; overpaid just means a refund is processed separately
    markOrderPaid(event.MerchantOrderID, event.AmountPaid)
case "partial":
    // underpaid — do NOT fulfil; wait for the rest under the same reference
    recordPartial(event.MerchantOrderID, event.AmountPaid, event.AmountRemaining)
}
w.WriteHeader(200)
w.Write([]byte(`{"received":true}`))

Webhook Security

Every webhook delivery is signed with HMAC-SHA256 using a secret specific to your merchant account. You verify the signature on your side to confirm the webhook originated from Dapit and the body was not modified in transit.

Your webhook signing secret is shown in Gateway Admin → Settings → Webhook Signing Secret. Copy it once during setup. If it ever leaks, rotate it immediately — the previous value stops working the instant you rotate.

Headers

Every signed webhook delivery includes two extra headers:

HeaderValue
X-Dapit-TimestampUnix timestamp (seconds) at the moment we signed the payload. Used to guard against replay attacks.
X-Dapit-Signaturesha256=<hex> where <hex> is the HMAC-SHA256 of timestamp + "." + raw_body, keyed with your webhook secret.

Verification algorithm

  1. Read the raw body (do not parse JSON yet).
  2. Read X-Dapit-Timestamp and X-Dapit-Signature.
  3. Reject the request if the timestamp is older than 5 minutes (replay guard).
  4. Compute expected = hmac_sha256(timestamp + "." + raw_body, webhook_secret).
  5. Strip the sha256= prefix from X-Dapit-Signature.
  6. Compare with a constant-time string comparison (crypto.timingSafeEqual in Node, hash_equals in PHP).
  7. If they match, the webhook is authentic. Only then parse the JSON and act on it.

Verify the signature

One verified endpoint per language. Each reads the raw body (never the parsed JSON), rejects stale timestamps, recomputes the HMAC over timestamp + "." + raw_body, and compares in constant time. Pick your stack:

// Express / Node 18+
import express from "express";
import crypto from "node:crypto";

const SECRET  = process.env.DAPIT_WEBHOOK_SECRET;   // whsec_...
const MAX_AGE = 300;                                // 5 minutes

const app = express();
// capture the RAW body — required for signature verification
app.use("/webhooks/dapit", express.raw({ type: "application/json" }));

app.post("/webhooks/dapit", (req, res) => {
  const ts  = req.header("X-Dapit-Timestamp");
  const sig = req.header("X-Dapit-Signature");
  const raw = req.body.toString("utf8");

  if (!ts || !sig) return res.status(400).send("Missing headers");
  if (Math.abs(Date.now() / 1000 - Number(ts)) > MAX_AGE)        // replay guard
    return res.status(400).send("Timestamp out of range");

  const expected = crypto.createHmac("sha256", SECRET)
                         .update(ts + "." + raw).digest("hex");
  const got = sig.startsWith("sha256=") ? sig.slice(7) : sig;
  const a = Buffer.from(expected, "hex"), b = Buffer.from(got, "hex");
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b))
    return res.status(401).send("Bad signature");

  const event = JSON.parse(raw);   // authentic — branch on event.payment_status
  res.status(200).json({ received: true });
});
# Flask
import hmac, hashlib, time, os, json
from flask import request

SECRET  = os.environ["DAPIT_WEBHOOK_SECRET"].encode()   # whsec_...
MAX_AGE = 300                                           # 5 minutes

@app.post("/webhooks/dapit")
def webhook():
    ts  = request.headers.get("X-Dapit-Timestamp", "")
    sig = request.headers.get("X-Dapit-Signature", "")
    raw = request.get_data()                            # RAW bytes — don't parse yet

    if not ts or not sig:
        return "Missing headers", 400
    if abs(time.time() - int(ts)) > MAX_AGE:            # replay guard
        return "Timestamp out of range", 400

    expected = hmac.new(SECRET, f"{ts}.".encode() + raw, hashlib.sha256).hexdigest()
    got = sig[7:] if sig.startswith("sha256=") else sig
    if not hmac.compare_digest(expected, got):
        return "Bad signature", 401

    event = json.loads(raw)   # authentic — branch on event["payment_status"]
    return {"received": True}, 200
<?php
$secret = getenv("DAPIT_WEBHOOK_SECRET");               // whsec_...
$ts     = $_SERVER["HTTP_X_DAPIT_TIMESTAMP"] ?? "";
$sig    = $_SERVER["HTTP_X_DAPIT_SIGNATURE"] ?? "";
$raw    = file_get_contents("php://input");             // RAW body

if (!$ts || abs(time() - (int)$ts) > 300) {             // replay guard, 5 min
    http_response_code(400); exit("Timestamp out of range");
}
$expected = hash_hmac("sha256", $ts . "." . $raw, $secret);
$got = str_starts_with($sig, "sha256=") ? substr($sig, 7) : $sig;
if (!hash_equals($expected, $got)) {
    http_response_code(401); exit("Bad signature");
}
$event = json_decode($raw, true);   // authentic — branch on $event["payment_status"]
http_response_code(200);
echo json_encode(["received" => true]);
// Spring Boot
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
public class DapitWebhook {
  static final String SECRET  = System.getenv("DAPIT_WEBHOOK_SECRET");  // whsec_...
  static final long   MAX_AGE = 300;                                    // seconds

  @PostMapping("/webhooks/dapit")
  public ResponseEntity<String> handle(
      @RequestBody String raw,                          // RAW body
      @RequestHeader("X-Dapit-Timestamp") String ts,
      @RequestHeader("X-Dapit-Signature") String sig) throws Exception {

    if (Math.abs(System.currentTimeMillis() / 1000 - Long.parseLong(ts)) > MAX_AGE)
      return ResponseEntity.status(400).body("Timestamp out of range");   // replay guard

    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(SECRET.getBytes(), "HmacSHA256"));
    byte[] h = mac.doFinal((ts + "." + raw).getBytes());
    StringBuilder hex = new StringBuilder();
    for (byte x : h) hex.append(String.format("%02x", x));
    String got = sig.startsWith("sha256=") ? sig.substring(7) : sig;
    if (!MessageDigest.isEqual(hex.toString().getBytes(), got.getBytes()))
      return ResponseEntity.status(401).body("Bad signature");

    // authentic — parse `raw` (Jackson) then branch on payment_status
    return ResponseEntity.ok("{\"received\":true}");
  }
}
// Go net/http
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

var secret = []byte(os.Getenv("DAPIT_WEBHOOK_SECRET"))   // whsec_...

func webhook(w http.ResponseWriter, r *http.Request) {
    raw, _ := io.ReadAll(r.Body)                         // RAW body
    ts  := r.Header.Get("X-Dapit-Timestamp")
    sig := r.Header.Get("X-Dapit-Signature")

    n, err := strconv.ParseInt(ts, 10, 64)
    if err != nil || abs(time.Now().Unix()-n) > 300 {    // replay guard
        http.Error(w, "Timestamp out of range", 400); return
    }
    m := hmac.New(sha256.New, secret)
    m.Write([]byte(ts + "." + string(raw)))
    expected := hex.EncodeToString(m.Sum(nil))
    got := sig
    if len(sig) > 7 && sig[:7] == "sha256=" { got = sig[7:] }
    if !hmac.Equal([]byte(expected), []byte(got)) {
        http.Error(w, "Bad signature", 401); return
    }
    // authentic — json.Unmarshal(raw, &event) then branch on payment_status
    w.WriteHeader(200)
    w.Write([]byte(`{"received":true}`))
}

func abs(x int64) int64 { if x < 0 { return -x }; return x }
Important: Verify the signature against the raw request body, not the parsed JSON object. Any reformatting (key reordering, whitespace changes) will change the bytes and the signature won't match. In Express, use express.raw() on the webhook route instead of express.json().
Defense in depth: Even with signatures, good hygiene is to also (1) restrict your webhook endpoint to HTTPS only, (2) treat reference_number as an idempotency key, and (3) for high-value orders, confirm by calling Payment Status back to our API before fulfilling.

Session & Transaction Statuses

StatusDescription
pendingSession created, customer hasn't reached checkout yet
invoicedCustomer is on the checkout page, invoice + reference generated
pollingconfirm_paid was called (typically when the customer tapped "I've Sent the Payment") — we're checking the custodial IBAN on a fast 5-7s cycle. Note: even without this status, the background reconciliation cron is checking every 60s.
partialPayment received but less than invoice amount — waiting for remaining balance
confirmedFull payment received and verified ✓
overpaidPayment received but more than invoice amount — refund will be processed (minus €1 fee)
failedPayment not detected after maximum polling attempts
expiredSession expired before payment was made

Error Handling

All errors return a JSON object with success: false and an error message:

{
  "success": false,
  "error": "Missing API key"
}
HTTP CodeMeaning
400Bad request — missing or invalid parameters
401Unauthorized — invalid or missing API key
404Not found — session or transaction doesn't exist

Testing & Environments

Every merchant account starts in staging mode. This lets you integrate and test the full payment flow against a sandbox banking environment before going live with real money.

Staging vs Production

FeatureStagingProduction
Real moneyNo — test transactions onlyYes — real EUR transfers
IBANStaging IBAN (provided during onboarding)Production IBAN
API KeySame key — environment is set on your merchant accountSame key
WebhookFires normally — test your endpointFires normally
How it works: Your API key is the same for both environments. When you're onboarded, your merchant account is set to staging. Once you've tested successfully, contact us and we'll flip your account to production. The create_session response includes an environment field so you always know which mode you're in.

Integration Checklist — Staging

  1. Onboard with Dapit and receive your mk_live_... API key (the same key works for staging and production)
  2. Confirm your account environment is set to staging — check the environment field in any create_session response
  3. Build your checkout flow using Quick Start Option A or Option B
  4. Test end-to-end with the test accounts below — send a small SEPA transfer from one of them to your staging custodial IBAN using your session's reference number
  5. Verify your webhook endpoint receives the payment.confirmed event with valid signature
  6. Once everything works, email us to flip your account to production

Test Accounts for Staging

Use these pre-funded test IBANs to simulate payments in the staging environment. Send EUR from either account to the staging custodial IBAN with the reference number from your checkout session.

Account NameIBANBICCurrencyBalance
Bank Has No Money LT923740020074597273 CNPALT21XXX EUR €100.00
Bank Has Money LT223740020032524104 CNPALT21XXX EUR €10,000.00
How to test: Create a checkout session via the API, then send a SEPA transfer from one of these test accounts to your staging custodial IBAN using the reference number. The system will detect the payment and confirm the session — just like production, but with test money.
Note: Test account balances are shared across all staging merchants. Use small amounts (€1–€5) for testing. Balances reset periodically.

Mock Confirm (staging only)

Simulates a confirmed payment against an open staging session, without you having to actually send a SEPA transfer. The endpoint mirrors what happens internally when the cron reconciler matches a real bank credit: the session is flipped to confirmed (or partial/overpaid depending on the amount), a transaction row is recorded, and your webhook is fired with a real-shape payload. The payload includes mock: true and recovery_method: "mock_confirm" so your idempotency layer can filter test events from production traffic. This endpoint is hard-refused in production — it only works for merchants whose account is currently flagged as staging.

POST ?action=mock_confirm

Flip an open staging session to confirmed and fire the merchant webhook, as if a real bank credit had landed.

Headers

NameValue
X-Api-KeyYour merchant API key (mk_...) REQUIRED
Content-Typeapplication/json

Request Body

ParameterTypeDescription
referencestringThe session's reference number (e.g. DP-260522-XXXXXX). One of reference or session_id REQUIRED
session_idnumberThe session's integer id. Alternative to reference.
amountnumberMock payment amount in EUR. Defaults to the full session amount. Use a value lower than the session amount to simulate a partial payment, or higher to simulate overpaid. OPTIONAL
debtor_namestringSynthetic payer name surfaced on the webhook payload. Default "Mock Sender (stage)". OPTIONAL
debtor_ibanstringSynthetic payer IBAN surfaced on the webhook payload. OPTIONAL

Response

{
  "success": true,
  "mock": true,
  "session_id": 42,
  "reference": "DP-260522-XXXXXX",
  "previous_status": "invoiced",
  "new_status": "confirmed",
  "amount_invoiced": 20.00,
  "amount_paid": 20.00,
  "amount_remaining": 0,
  "webhook_fired": true,
  "env": "stage",
  "note": "Stage-only mock confirmation. No money moved. Webhook payload includes mock:true."
}

Error Responses

HTTPWhen
400reference or session_id missing, or amount ≤ 0
403Your merchant account is on production. mock_confirm is rejected on production for safety.
404Session not found or not owned by your merchant account
409Session is already in confirmed, partial, or overpaid — mocking would double-fire the webhook

Example — curl

curl -X POST "https://www.maxafi.com/api/gateway/gateway.php?action=mock_confirm" \
  -H "X-Api-Key: mk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "reference": "DP-260522-XXXXXX",
    "amount": 20.00,
    "debtor_name": "Alice Test"
  }'

Webhook payload (delivered to your callback_url)

Identical shape to a real payment.confirmed webhook, with two added fields you can use to filter test events:

{
  "event": "payment.confirmed",
  "payment_status": "confirmed",
  "reference_number": "DP-260522-XXXXXX",
  "invoice_number": "INV-260522-00042",
  "merchant_order_id": "ORD-12345",
  "invoice_amount": 20.00,
  "amount_paid": 20.00,
  "amount_remaining": 0,
  "overpaid_amount": 0,
  "refund_amount": 0,
  "refund_fee": 0,
  "currency": "EUR",
  "paid_at": "2026-05-22 14:09:55",
  "session_token": "a1b2c3d4e5f6...",
  "customer_email": "john@example.com",
  "customer_name": "John Smith",
  "mock": true,
  "recovery_method": "mock_confirm"
}

The two trailing fields — mock and recovery_method — are present only on mock_confirm fires; everything above them is byte-identical to a real settlement. Filter on mock === true to skip side effects in CI/dev. To exercise partial or overpaid handling, pass an amount below or above the invoice — the payload then matches the partial / overpaid examples above, with mock: true appended.

Recommended testing pattern:
  1. Call create_session → get a reference and session_token.
  2. (Optional) Render create_invoice on a test checkout page to see what your customer would see.
  3. Call mock_confirm with the reference. Your webhook receiver gets the confirmed-event payload within seconds — no SEPA transfer needed.
  4. In your webhook handler, check for mock === true in CI/dev environments to skip side effects (like sending receipt emails to real addresses).

This lets you build and exercise the full webhook-handler code path in your CI suite without any banking integration on your side.

Safety: mock_confirm writes to the same database tables as real settlements, but the session, transaction, and webhook log rows are all flagged as mock in their audit metadata. They will not appear in settlement reports or production reconciliation queries. Still — do not point mock_confirm at sessions you actually care about, since it advances state machines irreversibly. The endpoint refuses sessions already in a paid state for this reason.

Send Test Webhook

Fires a synthetic payment.confirmed payload at your configured callback_url so you can verify your webhook endpoint is reachable, your signature verification works, and your handler parses the payload correctly — without waiting for a real transaction. Useful during initial integration, after deploying a new webhook handler, or when an integrator says "we're not receiving webhooks" and you want to rule out a connectivity issue in 5 seconds. The endpoint works in both staging and production (it's harmless — your handler can detect the test via test: true in the payload).

POST ?action=send_test_webhook

POSTs a synthetic payment.confirmed payload to your saved callback_url. Returns the HTTP response code and timing immediately so you can see whether your endpoint accepted it.

Headers

NameValue
X-Api-KeyYour merchant API key (mk_...) REQUIRED

Request Body

None. The endpoint reads your saved callback_url and webhook_secret from your merchant configuration and uses those.

What gets sent

Your endpoint receives a fully-formed payment.confirmed webhook with realistic values (€10.00, EUR, synthetic debtor name and IBAN). Two flags identify it as a test:

  • test: true — top-level flag so your handler can filter test fires out of analytics, billing, or shipping logic.
  • reference_number starts with TEST- (e.g. TEST-A3F2B1C4) instead of the normal DP-YYMMDD-XXXXXX prefix. Won't collide with real references.

If your account has a webhook_secret configured, the test webhook is HMAC-signed with the same X-Dapit-Timestamp and X-Dapit-Signature headers as production deliveries — so your signature verification path is exercised identically.

Response

{
  "success": true,
  "http_code": 200,
  "response_ms": 47,
  "response_body": "OK",
  "error": null,
  "test_reference": "TEST-A3F2B1C4",
  "callback_url": "https://yoursite.com/webhooks/dapit",
  "signed": true,
  "note": "Endpoint responded with 200 in 47ms. Your integration is reachable."
}

If your endpoint is unreachable or returns non-2xx, success is false, error describes the failure (Network error: Connection refused, Endpoint returned HTTP 502, etc.), and http_code may be 0 for connection-level failures.

Error Responses

HTTPWhen
200 with success: falseTest fire was attempted but your endpoint rejected it or was unreachable. See error and http_code for the specific reason. Your callback_url is wrong, your server is down, your firewall is blocking us, or your handler returns non-2xx.
400Your merchant account has no callback_url configured. Set it in your merchant settings, then try again.
401Missing or invalid API key

Example — curl

curl -X POST "https://www.maxafi.com/api/gateway/gateway.php?action=send_test_webhook" \
  -H "X-Api-Key: mk_live_YOUR_KEY"

Audit trail

Every test fire is logged to gateway_webhook_log with triggered_by='test_webhook' alongside real deliveries — visible in the admin Transaction Inspector. Use this if you need historical evidence that your endpoint was tested and the result, e.g. for an internal compliance review or onboarding checklist.

Integration recipe. Use Send Test Webhook to close the integration loop in seconds:
  1. Deploy your webhook handler to your dev / stage environment.
  2. Set your callback_url in the merchant settings (admin UI or API).
  3. Call send_test_webhook. Within ~50ms you'll see the HTTP code your endpoint returned.
  4. If 200 — you're done; your endpoint is reachable. Move on to mock_confirm to exercise the full session lifecycle.
  5. If non-200 or connection-level error — fix the issue your error field describes, repeat step 3.

Integration Checklist

Going Live

Demo Store: Visit /checkout/ to see a working example of the full checkout flow — from product selection to payment confirmation. View source to see how it integrates.

Request Refund

Flag a completed payment for refund. This submits the refund for processing — funds are sent back to the original customer in due course. The merchant does not execute the outbound payment; Dapit processes it.

POST /api/gateway/gateway.php?action=create_refund
ParameterTypeDescription
api_keystring requiredYour merchant API key
referencestring requiredOriginal payment reference number
amountnumber requiredRefund amount (must be ≤ original payment amount)
reasonstring optionalReason for refund

Response:

{
  "success": true,
  "refund_id": 42,
  "reference_number": "R7A3F2B1",
  "amount": 25.00,
  "processing_fee": 1.00,
  "status": "pending"
}
Status lifecycle. Newly requested refunds return status: "pending". Subsequent processing transitions the refund through internal states and the final outbound payment is initiated by Dapit. Use List Refund Requests to track current status. The merchant is not required (and is not able) to trigger the outbound payment via the API.

List Refund Requests

GET /api/gateway/gateway.php?action=list_refunds&api_key=KEY

Returns all refund requests for the authenticated merchant, including current status. Optionally filter by reference or status.

Settlement Report

Get an aggregated settlement report for a date range — gross volume, refunds, fees, and net settlement amount with daily breakdown.

POST /api/gateway/gateway.php?action=settlement_report
ParameterTypeDescription
api_keystring requiredYour merchant API key
date_fromstring requiredStart date (YYYY-MM-DD)
date_tostring requiredEnd date (YYYY-MM-DD)

Response includes: gross_volume, total_refunds, total_fees, net_settlement, daily_breakdown array, and transaction_count.

Dashboard

GET /api/gateway/gateway.php?action=dashboard&api_key=KEY

Returns merchant dashboard KPIs: today's volume, this month's volume, all-time volume, success rate, average transaction, 7-day trend, and recent transactions.

Recurring Billing — Overview

The recurring billing system sends periodic payment reminders to customers via SMS and/or email. Each cycle generates a new reference number and a link back to the merchant's checkout page. The customer must voluntarily initiate each payment — this is not a direct debit.

1
Customer opts into recurring at checkout (or merchant creates schedule via API)
2
System stores order: items, amount, frequency, customer contact info
3
Cron job runs every 15 min — finds due schedules, generates new reference
4
System sends SMS and/or email notification with payment link
5
Customer clicks link → merchant checkout → pays with new reference
6
System detects payment → marks cycle paid → schedules next cycle

Base URL:

https://www.maxafi.com/api/recurring.php

Authentication: Same gateway merchant API key via X-Api-Key header or api_key parameter.

Create Schedule

POST /api/recurring.php
ParameterTypeDescription
actionstring requiredcreate_schedule
customer_namestring requiredCustomer's full name
customer_emailstringEmail for payment link delivery
customer_phonestringPhone with country code (e.g. +4712345678) for SMS
delivery_methodstringemail, sms, or both (default: email)
frequencystring requiredweekly, biweekly, bimonthly, monthly
amountnumber requiredPayment amount per cycle (before discount)
discount_percentnumberRecurring discount (e.g. 5 for 5% off)
currencystringCurrency code (default: EUR)
order_itemsarrayJSON array of cart items: [{"name":"...", "qty":1, "price":99}]
order_descriptionstringDescription shown to customer
payment_urlstringYour checkout URL — customer is sent here each cycle
start_datestringFirst billing date (YYYY-MM-DD, default: tomorrow)
total_cyclesintegerMax cycles (0 = unlimited)

Example:

curl -X POST https://www.maxafi.com/api/recurring.php \
  -H "X-Api-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "create_schedule",
    "customer_name": "John Smith",
    "customer_email": "john@example.com",
    "customer_phone": "+4712345678",
    "delivery_method": "both",
    "frequency": "monthly",
    "amount": 99.00,
    "discount_percent": 5,
    "currency": "EUR",
    "order_items": [{"name": "Premium Plan", "qty": 1, "price": 99}],
    "payment_url": "https://merchant-site.com/pay"
  }'

Response:

{
  "success": true,
  "schedule_id": 7,
  "amount": 94.05,
  "original_amount": 99.00,
  "discount_percent": 5,
  "frequency": "monthly",
  "next_billing_date": "2026-04-03"
}

List Schedules

GET /api/recurring.php?action=list_schedules&api_key=KEY

Returns all recurring schedules for the merchant. Optionally filter by status (active, paused, cancelled, completed).

Update Schedule

POST /api/recurring.php

Update a schedule: pause, resume, cancel, change amount or frequency.

ParameterTypeDescription
actionstringupdate_schedule
schedule_idinteger requiredSchedule to update
statusstringactive, paused, cancelled
amountnumberNew amount per cycle
frequencystringNew frequency
next_billing_datestringOverride next billing date (YYYY-MM-DD)