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.
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 OKstatus - ✅ 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
| Field | Type | Description |
|---|---|---|
transaction_key | string | Transaction identifier |
status | string | Current transaction status |
wallet | integer | Payer's wallet ID |
created_at | timestamp | When created |
confirmed_at | timestamp | When confirmed (if confirmed) |
payments | array | Array 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
| Status | When Sent | What It Means |
|---|---|---|
waiting | After user accepts | Funds reserved, not confirmed yet |
confirmed | After you confirm | Payment complete, funds transferred |
done | After finalization | Frozen payment finalized |
canceled | After cancellation | Payment cancelled, funds released |
failed | On failure | Payment 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
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:
- ✅ URL is publicly accessible
- ✅ HTTPS certificate is valid
- ✅ Firewall allows Paysera IPs
- ✅ Server is running
- ✅ 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:
- Transaction Requests - Request payments from users (coming soon)
- Reservation Codes - Generate payment codes (coming soon)
- Authorising Transactions - All acceptance methods
Need Help?
- Questions? → tech_support@paysera.com
- Transaction Guide → Transaction Resource
- Examples → Code Samples
Always: Return 200 OK
Verify: Check with API
Be idempotent: Handle duplicates
Process quickly: < 30 seconds
Log everything: For debugging