Skip to main content

Payment Callbacks

Callbacks (webhooks) allow you to receive real-time notifications when payment status changes. This is essential for reliable payment processing and should be your primary method for handling payment confirmations.

Why Callbacks?

Never rely solely on user redirects - users may close their browser or have network issues. Callbacks ensure you always know about payment status changes.

How Callbacks Work

Key points:

  • Callbacks are independent of user redirects
  • Sent when transaction status changes
  • Your server must respond with 200 OK
  • Retried if delivery fails

Setting Up Callbacks

Configure Callback URL

Set when creating transaction:

POST /rest/v1/transaction

{
"payments": [{
"description": "Order #1234",
"price": 2999
}],
"redirect_uri": "https://yoursite.com/payment-return",
"callback_uri": "https://yoursite.com/payment-callback" // ← Callback URL
}

Callback Endpoint Requirements

Your callback endpoint must:

  • ✅ Be publicly accessible (HTTPS recommended)
  • ✅ Respond with 200 OK status
  • ✅ Process request quickly (< 30 seconds)
  • ✅ Be idempotent (handle duplicates)
  • ✅ Validate request authenticity

Callback Request Format

HTTP Request

POST /payment-callback HTTP/1.1
Host: yoursite.com
Content-Type: application/json
User-Agent: Paysera-Callback/1.0

{
"transaction_key": "pDAlAZ3z",
"status": "confirmed",
"wallet": 14471,
"created_at": 1698675600,
"confirmed_at": 1698675680,
"payments": [
{
"id": 2988,
"price": 2999,
"currency": "EUR",
"status": "done"
}
]
}

Callback Data

FieldTypeDescription
transaction_keystringTransaction identifier
statusstringCurrent transaction status
walletintegerPayer's wallet ID
created_attimestampWhen created
confirmed_attimestampWhen confirmed (if confirmed)
paymentsarrayArray of payment objects

Implementation

View Implementation Examples

Basic Callback Handler

const express = require('express');
const app = express();

app.post('/payment-callback', express.json(), async (req, res) => {
try {
const { transaction_key, status, payments } = req.body;

console.log(`Callback received for ${transaction_key}: ${status}`);

// Process based on status
if (status === 'confirmed' || status === 'done') {
await handleSuccessfulPayment(transaction_key, payments);
} else if (status === 'canceled' || status === 'failed') {
await handleFailedPayment(transaction_key);
}

// IMPORTANT: Always respond with 200 OK
res.status(200).send('OK');

} catch (error) {
console.error('Callback processing error:', error);
// Still respond with 200 to prevent retries
res.status(200).send('OK');
}
});

async function handleSuccessfulPayment(transactionKey, payments) {
// 1. Verify transaction with API (recommended)
const transaction = await getTransaction(transactionKey);

if (transaction.status !== 'confirmed' && transaction.status !== 'done') {
console.warn('Transaction status mismatch!');
return;
}

// 2. Update order status
await updateOrderStatus(transactionKey, 'paid');

// 3. Send confirmation email
await sendConfirmationEmail(transactionKey);

// 4. Start fulfillment
await startOrderFulfillment(transactionKey);
}

async function handleFailedPayment(transactionKey) {
await updateOrderStatus(transactionKey, 'payment_failed');
await sendPaymentFailedEmail(transactionKey);
}

Complete Production Handler

class CallbackHandler {
async handleCallback(req, res) {
const { transaction_key, status } = req.body;

try {
// 1. Check if already processed (idempotency)
const processed = await this.isCallbackProcessed(transaction_key, status);
if (processed) {
return res.status(200).send('OK');
}

// 2. Verify with API (security)
const transaction = await this.paymentService.getTransaction(transaction_key);

if (transaction.status !== req.body.status) {
console.error('Status mismatch in callback!');
return res.status(200).send('OK');
}

// 3. Process based on status
await this.processCallback(transaction);

// 4. Mark as processed
await this.markCallbackProcessed(transaction_key, status);

// 5. Respond quickly
res.status(200).send('OK');

} catch (error) {
console.error('Callback error:', error);
res.status(200).send('OK');
}
}
}

Callback Statuses

View Callback Statuses

When Callbacks Are Sent

StatusWhen SentWhat It Means
waitingAfter user acceptsFunds reserved, not confirmed yet
confirmedAfter you confirmPayment complete, funds transferred
doneAfter finalizationFrozen payment finalized
canceledAfter cancellationPayment cancelled, funds released
failedOn failurePayment failed

Handling Each Status

async function handleCallbackStatus(transaction) {
switch (transaction.status) {
case 'waiting':
// User accepted, waiting for confirmation
await confirmTransaction(transaction.transaction_key);
break;

case 'confirmed':
// Payment successful!
await markOrderPaid(transaction.transaction_key);
await sendConfirmation(transaction);
await startFulfillment(transaction);
break;

case 'done':
// Frozen payment finalized
await finalizeOrder(transaction.transaction_key);
break;

case 'canceled':
// User or system cancelled
await markOrderCanceled(transaction.transaction_key);
break;

case 'failed':
// Payment failed
await markOrderFailed(transaction.transaction_key);
break;
}
}

Advanced Topics

Security

Verify Callbacks

Always verify callbacks are genuine:

async function verifyCallback(req) {
const { transaction_key } = req.body;

// 1. Fetch transaction from API
const transaction = await getTransaction(transaction_key);

// 2. Compare status
if (transaction.status !== req.body.status) {
throw new Error('Status mismatch - possible fake callback');
}

// 3. Verify payments match
if (transaction.payments.length !== req.body.payments.length) {
throw new Error('Payment count mismatch');
}

return true;
}

IP Whitelisting (Optional)

const PAYSERA_IPS = ['1.2.3.4', '5.6.7.8'];  // Get actual IPs from Paysera

function validateIP(req) {
const clientIP = req.ip || req.connection.remoteAddress;
if (!PAYSERA_IPS.includes(clientIP)) {
throw new Error('Callback from unauthorized IP');
}
}
Handling Failures

Retry Logic

Paysera will retry failed callbacks:

  • Retry attempts: Up to 10 times
  • Retry interval: Exponential backoff
  • Timeout: 30 seconds per attempt

Your Responsibilities

app.post('/payment-callback', async (req, res) => {
try {
await processCallback(req.body);
res.status(200).send('OK');
} catch (error) {
console.error('Callback error:', error);
await logError(error, req.body);

// STILL respond with 200 OK to prevent retries
res.status(200).send('OK');

// Queue for manual review
await queueForManualReview(req.body);
}
});
Testing Callbacks
Production Testing

Wallet API does not have a sandbox environment. Test callbacks in production with small amounts.

Local Testing with ngrok

# 1. Install ngrok
npm install -g ngrok

# 2. Start your server
node server.js # Running on port 3000

# 3. Create tunnel
ngrok http 3000

# 4. Use ngrok URL in callback_uri
# https://abc123.ngrok.io/payment-callback

Manual Test

curl -X POST https://yoursite.com/payment-callback \
-H "Content-Type: application/json" \
-d '{
"transaction_key": "test123",
"status": "confirmed",
"wallet": 14471,
"payments": [{"id": 1, "price": 1000, "status": "done"}]
}'
Common Patterns

Pattern 1: Async Processing

app.post('/payment-callback', async (req, res) => {
// Respond immediately
res.status(200).send('OK');

// Process async
setImmediate(async () => {
try {
await processPayment(req.body);
} catch (error) {
await queueForRetry(req.body);
}
});
});

Pattern 2: Database Transaction

async function handleCallback(data) {
const transaction = await db.beginTransaction();

try {
// Check if processed
const existing = await transaction.query(
'SELECT * FROM callbacks WHERE transaction_key = ? FOR UPDATE',
[data.transaction_key]
);

if (existing.length > 0) {
await transaction.rollback();
return;
}

// Process callback
await transaction.query(
'UPDATE orders SET status = ? WHERE transaction_key = ?',
['paid', data.transaction_key]
);

// Mark as processed
await transaction.query(
'INSERT INTO callbacks (transaction_key, status) VALUES (?, ?)',
[data.transaction_key, data.status]
);

await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}

Pattern 3: Event-Driven

const EventEmitter = require('events');
const paymentEvents = new EventEmitter();

// Callback handler
app.post('/payment-callback', (req, res) => {
paymentEvents.emit('payment-status-changed', req.body);
res.status(200).send('OK');
});

// Event listeners
paymentEvents.on('payment-status-changed', async (data) => {
if (data.status === 'confirmed') {
await handleSuccessfulPayment(data);
}
});
Troubleshooting

Callbacks Not Received

Check:

  1. ✅ URL is publicly accessible
  2. ✅ HTTPS certificate is valid
  3. ✅ Firewall allows Paysera IPs
  4. ✅ Server is running
  5. ✅ No timeout issues

Test:

curl -X POST https://yoursite.com/payment-callback \
-H "Content-Type: application/json" \
-d '{"test": true}'

Duplicate Callbacks

Normal: Retries after failures or timeouts

Solution: Implement idempotency

async function handleCallback(data) {
const key = `callback:${data.transaction_key}:${data.status}`;

// Check Redis
const processed = await redis.get(key);
if (processed) {
return;
}

// Process
await processPayment(data);

// Mark as processed (24h expiry)
await redis.setex(key, 86400, '1');
}

What's Next?

Learn about other integration methods:

  1. Transaction Requests - Request payments from users (coming soon)
  2. Reservation Codes - Generate payment codes (coming soon)
  3. Authorising Transactions - All acceptance methods

Need Help?

Quick Reference

Always: Return 200 OK
Verify: Check with API
Be idempotent: Handle duplicates
Process quickly: < 30 seconds
Log everything: For debugging