Error Handling & Solutions
Comprehensive guide to understanding and handling errors in Paysera Open Banking API.
Overview​
When errors occur, the Open Banking API returns:
- HTTP status code (400-5xx) indicating the error type
- Structured error response with detailed information
- Error codes following Berlin Group specification
- Optional retry information (Retry-After header for 429 errors)
Standard Error Response Format​
All error responses follow this structure:
{
"tppMessages": [
{
"category": "ERROR",
"code": "CONSENT_EXPIRED",
"text": "The consent has expired",
"path": "/v1/accounts",
"validUntil": "2026-02-09T23:59:59Z"
}
]
}
Fields:
category- ERROR or WARNINGcode- Berlin Group error codetext- Human-readable descriptionpath- (Optional) API path that caused the error- Additional context fields depending on error type
Error Handling Flow​
Common Error Scenarios​
1. Invalid Certificate (401 Unauthorized)​
Error Code: CERTIFICATE_INVALID
Why it occurs:
- Certificate has expired
- Certificate CN doesn't match TPP identifier
- Incomplete certificate chain
- Self-signed certificate used in production
- Certificate revoked (OCSP check failed)
Example Response:
{
"tppMessages": [
{
"category": "ERROR",
"code": "CERTIFICATE_INVALID",
"text": "The provided certificate is not valid for this TPP"
}
]
}
Solutions:
- Verify Certificate
- JavaScript
- Python
- PHP
# Check certificate expiration
openssl x509 -in qwac-cert.pem -noout -dates
# Check certificate CN matches TPP ID
openssl x509 -in qwac-cert.pem -noout -subject
# Verify certificate chain
openssl verify -CAfile ca-bundle.pem qwac-cert.pem
# Check OCSP status
openssl ocsp -issuer issuer-cert.pem -cert qwac-cert.pem \
-url http://ocsp.provider.com -CAfile ca-bundle.pem
const https = require('https');
const fs = require('fs');
// Load certificates
const options = {
key: fs.readFileSync('qwac-key.pem'),
cert: fs.readFileSync('qwac-cert.pem'),
ca: fs.readFileSync('ca-bundle.pem'),
rejectUnauthorized: true // Always true in production
};
// Make request with certificate validation
https.get('https://open-banking-api.paysera.com/v1/accounts', options, (res) => {
if (res.statusCode === 401) {
console.error('Certificate validation failed');
// Check certificate validity
const certExpiry = new Date(res.socket.getPeerCertificate().valid_to);
if (certExpiry < new Date()) {
console.error('Certificate expired:', certExpiry);
}
}
});
import requests
from OpenSSL import crypto
from datetime import datetime
def check_certificate_validity(cert_path):
"""Verify certificate is valid"""
with open(cert_path, 'r') as f:
cert = crypto.load_certificate(crypto.FILETYPE_PEM, f.read())
# Check expiration
expiry = datetime.strptime(
cert.get_notAfter().decode('utf-8'),
'%Y%m%d%H%M%SZ'
)
if expiry < datetime.now():
raise ValueError(f'Certificate expired: {expiry}')
# Check subject CN
subject = cert.get_subject()
print(f'Certificate CN: {subject.CN}')
return True
# Verify before making requests
try:
check_certificate_validity('qwac-cert.pem')
# Make request with certificates
response = requests.get(
'https://open-banking-api.paysera.com/v1/accounts',
cert=('qwac-cert.pem', 'qwac-key.pem'),
verify='ca-bundle.pem'
)
except ValueError as e:
print(f'Certificate error: {e}')
<?php
// Verify certificate validity
function verifyCertificate($certPath) {
$cert = openssl_x509_read(file_get_contents($certPath));
// Check expiration
$certData = openssl_x509_parse($cert);
$expiryDate = date('Y-m-d H:i:s', $certData['validTo_time_t']);
if (time() > $certData['validTo_time_t']) {
throw new Exception("Certificate expired: $expiryDate");
}
// Check subject CN
echo "Certificate CN: " . $certData['subject']['CN'] . "\n";
return true;
}
try {
verifyCertificate('qwac-cert.pem');
// Configure cURL with certificates
$ch = curl_init('https://open-banking-api.paysera.com/v1/accounts');
curl_setopt($ch, CURLOPT_SSLCERT, 'qwac-cert.pem');
curl_setopt($ch, CURLOPT_SSLKEY, 'qwac-key.pem');
curl_setopt($ch, CURLOPT_CAINFO, 'ca-bundle.pem');
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
$response = curl_exec($ch);
if (curl_errno($ch) === 58) { // SSL certificate problem
throw new Exception('Certificate validation failed');
}
} catch (Exception $e) {
error_log('Certificate error: ' . $e->getMessage());
}
?>
Checklist:
- ✅ Certificate not expired (valid for 2 years from issuance)
- ✅ CN matches your TPP authorization number
- ✅ Complete certificate chain included
- ✅ Using qualified certificate from authorized CA
- ✅ Certificate not revoked
2. Consent Expired (401 Unauthorized)​
Error Code: CONSENT_EXPIRED
Why it occurs:
- AIS consent validity period has passed (90 days maximum)
- User has revoked consent
- Consent was deleted
- Accessing resources outside consent scope
Example Response:
{
"tppMessages": [
{
"category": "ERROR",
"code": "CONSENT_EXPIRED",
"text": "The consent has expired",
"validUntil": "2026-02-09T23:59:59Z"
}
]
}
Consent Expiration & Renewal Flow:
Solutions:
- JavaScript
- Python
- PHP
class ConsentManager {
constructor(apiClient) {
this.apiClient = apiClient;
}
async checkConsentValidity(consentId) {
try {
const consent = await this.apiClient.get(`/v1/consents/${consentId}`);
const validUntil = new Date(consent.validUntil);
const now = new Date();
const daysRemaining = Math.floor((validUntil - now) / (1000 * 60 * 60 * 24));
// Warn if consent expires soon
if (daysRemaining <= 7) {
console.warn(`Consent expires in ${daysRemaining} days`);
return { valid: true, renewSoon: true };
}
return { valid: true, renewSoon: false };
} catch (error) {
if (error.response?.data?.tppMessages?.[0]?.code === 'CONSENT_EXPIRED') {
return { valid: false, needsRenewal: true };
}
throw error;
}
}
async renewConsent(expiredConsentId) {
// Get original consent details
const oldConsent = await this.apiClient.get(`/v1/consents/${expiredConsentId}`);
// Create new consent with same scope
const newConsent = await this.apiClient.post('/v1/consents', {
access: oldConsent.access,
recurringIndicator: true,
validUntil: this.calculateValidUntil(90), // 90 days
frequencyPerDay: 4
});
// Return authorization URL for user
return {
consentId: newConsent.consentId,
authorizationUrl: newConsent._links.scaRedirect.href
};
}
calculateValidUntil(days) {
const date = new Date();
date.setDate(date.getDate() + days);
return date.toISOString().split('T')[0];
}
}
// Usage
const consentManager = new ConsentManager(apiClient);
async function fetchAccounts(consentId) {
try {
// Check consent before using
const status = await consentManager.checkConsentValidity(consentId);
if (!status.valid) {
// Trigger consent renewal flow
const renewal = await consentManager.renewConsent(consentId);
// Redirect user to authorize new consent
window.location.href = renewal.authorizationUrl;
return;
}
if (status.renewSoon) {
// Proactively notify user
console.log('Please renew your consent soon');
}
// Proceed with API call
return await apiClient.get('/v1/accounts');
} catch (error) {
if (error.response?.data?.tppMessages?.[0]?.code === 'CONSENT_EXPIRED') {
// Handle expired consent
const renewal = await consentManager.renewConsent(consentId);
window.location.href = renewal.authorizationUrl;
}
throw error;
}
}
from datetime import datetime, timedelta
import requests
class ConsentManager:
def __init__(self, api_client):
self.api_client = api_client
def check_consent_validity(self, consent_id):
"""Check if consent is still valid"""
try:
consent = self.api_client.get(f'/v1/consents/{consent_id}')
valid_until = datetime.fromisoformat(consent['validUntil'].replace('Z', '+00:00'))
days_remaining = (valid_until - datetime.now()).days
if days_remaining <= 0:
return {'valid': False, 'needs_renewal': True}
if days_remaining <= 7:
print(f'Warning: Consent expires in {days_remaining} days')
return {'valid': True, 'renew_soon': True}
return {'valid': True, 'renew_soon': False}
except requests.HTTPError as e:
if e.response.status_code == 401:
error_code = e.response.json()['tppMessages'][0]['code']
if error_code == 'CONSENT_EXPIRED':
return {'valid': False, 'needs_renewal': True}
raise
def renew_consent(self, expired_consent_id):
"""Create new consent to replace expired one"""
# Get original consent
old_consent = self.api_client.get(f'/v1/consents/{expired_consent_id}')
# Create new consent
valid_until = (datetime.now() + timedelta(days=90)).date().isoformat()
new_consent = self.api_client.post('/v1/consents', json={
'access': old_consent['access'],
'recurringIndicator': True,
'validUntil': valid_until,
'frequencyPerDay': 4
})
return {
'consent_id': new_consent['consentId'],
'authorization_url': new_consent['_links']['scaRedirect']['href']
}
# Usage
consent_manager = ConsentManager(api_client)
def fetch_accounts(consent_id):
"""Fetch accounts with automatic consent renewal"""
status = consent_manager.check_consent_validity(consent_id)
if not status['valid']:
# Trigger renewal
renewal = consent_manager.renew_consent(consent_id)
print(f'Consent expired. Please authorize: {renewal["authorization_url"]}')
return None
if status.get('renew_soon'):
print('Consider renewing consent soon')
try:
return api_client.get('/v1/accounts')
except requests.HTTPError as e:
if e.response.status_code == 401:
# Handle unexpected expiration
renewal = consent_manager.renew_consent(consent_id)
print(f'Please authorize new consent: {renewal["authorization_url"]}')
raise
<?php
class ConsentManager {
private $apiClient;
public function __construct($apiClient) {
$this->apiClient = $apiClient;
}
public function checkConsentValidity($consentId) {
try {
$consent = $this->apiClient->get("/v1/consents/$consentId");
$validUntil = new DateTime($consent['validUntil']);
$now = new DateTime();
$daysRemaining = $now->diff($validUntil)->days;
if ($daysRemaining <= 0) {
return ['valid' => false, 'needsRenewal' => true];
}
if ($daysRemaining <= 7) {
error_log("Warning: Consent expires in $daysRemaining days");
return ['valid' => true, 'renewSoon' => true];
}
return ['valid' => true, 'renewSoon' => false];
} catch (Exception $e) {
if ($e->getCode() === 401) {
$response = json_decode($e->getMessage(), true);
if ($response['tppMessages'][0]['code'] === 'CONSENT_EXPIRED') {
return ['valid' => false, 'needsRenewal' => true];
}
}
throw $e;
}
}
public function renewConsent($expiredConsentId) {
// Get original consent
$oldConsent = $this->apiClient->get("/v1/consents/$expiredConsentId");
// Calculate new validity (90 days)
$validUntil = (new DateTime())->add(new DateInterval('P90D'))->format('Y-m-d');
// Create new consent
$newConsent = $this->apiClient->post('/v1/consents', [
'access' => $oldConsent['access'],
'recurringIndicator' => true,
'validUntil' => $validUntil,
'frequencyPerDay' => 4
]);
return [
'consentId' => $newConsent['consentId'],
'authorizationUrl' => $newConsent['_links']['scaRedirect']['href']
];
}
}
// Usage
$consentManager = new ConsentManager($apiClient);
function fetchAccounts($consentId) {
global $consentManager, $apiClient;
$status = $consentManager->checkConsentValidity($consentId);
if (!$status['valid']) {
// Trigger renewal
$renewal = $consentManager->renewConsent($consentId);
header("Location: " . $renewal['authorizationUrl']);
exit;
}
if ($status['renewSoon']) {
// Notify user
echo "Please renew your consent soon\n";
}
try {
return $apiClient->get('/v1/accounts');
} catch (Exception $e) {
if ($e->getCode() === 401) {
$renewal = $consentManager->renewConsent($consentId);
header("Location: " . $renewal['authorizationUrl']);
exit;
}
throw $e;
}
}
?>
Best Practices:
- ✅ Check consent validity before making API calls
- ✅ Renew consents 7 days before expiration
- ✅ Implement automatic renewal flow
- ✅ Notify users proactively about expiring consents
- ✅ Store consent expiration dates in your database
3. Rate Limit Exceeded (429 Too Many Requests)​
Error Code: ACCESS_EXCEEDED
Why it occurs:
- AIS without user presence: Exceeded 4 requests per day per consent
- General rate limit: Exceeded 10 requests per second
- Hourly limit: Exceeded 1000 requests per hour
- Burst limit exceeded (20 concurrent requests)
Example Response:
{
"tppMessages": [
{
"category": "ERROR",
"code": "ACCESS_EXCEEDED",
"text": "Access frequency limit exceeded. The limit is 4 requests per day per consent."
}
]
}
Headers:
X-RateLimit-Limit: 4
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1707609600
Retry-After: 86400
Rate Limit Handling Flow:
Solutions:
- JavaScript
- Python
- PHP
class RateLimitHandler {
constructor() {
this.retryQueue = [];
}
async makeRequest(url, options = {}) {
try {
const response = await fetch(url, options);
// Check rate limit headers
const remaining = parseInt(response.headers.get('X-RateLimit-Remaining'));
const reset = parseInt(response.headers.get('X-RateLimit-Reset'));
if (remaining <= 1) {
const resetDate = new Date(reset * 1000);
console.warn(`Rate limit almost exceeded. Resets at: ${resetDate}`);
}
if (response.status === 429) {
return await this.handleRateLimit(response, url, options);
}
return response;
} catch (error) {
throw error;
}
}
async handleRateLimit(response, url, options) {
const retryAfter = parseInt(response.headers.get('Retry-After')) || 60;
const resetTime = parseInt(response.headers.get('X-RateLimit-Reset'));
console.log(`Rate limit exceeded. Retrying after ${retryAfter} seconds`);
// Exponential backoff strategy
const delay = Math.min(retryAfter * 1000, 3600000); // Max 1 hour
await this.sleep(delay);
// Retry request
return await this.makeRequest(url, options);
}
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Proactive rate limiting with queue
async queueRequest(url, options, priority = 'normal') {
return new Promise((resolve, reject) => {
this.retryQueue.push({ url, options, priority, resolve, reject });
this.processQueue();
});
}
async processQueue() {
if (this.processing || this.retryQueue.length === 0) return;
this.processing = true;
// Sort by priority
this.retryQueue.sort((a, b) => {
const priorities = { high: 3, normal: 2, low: 1 };
return priorities[b.priority] - priorities[a.priority];
});
const item = this.retryQueue.shift();
try {
// Add delay between requests (100ms = max 10 req/s)
await this.sleep(100);
const response = await this.makeRequest(item.url, item.options);
item.resolve(response);
} catch (error) {
item.reject(error);
} finally {
this.processing = false;
if (this.retryQueue.length > 0) {
this.processQueue();
}
}
}
}
// Usage
const rateLimiter = new RateLimitHandler();
// Simple usage with automatic retry
const accounts = await rateLimiter.makeRequest(
'https://open-banking-api.paysera.com/v1/accounts',
{
headers: { 'Authorization': `Bearer ${token}` }
}
);
// Queued requests with priority
const balances = await rateLimiter.queueRequest(
'https://open-banking-api.paysera.com/v1/accounts/123/balances',
{ headers: { 'Authorization': `Bearer ${token}` } },
'high' // Priority: high, normal, low
);
import time
import requests
from datetime import datetime
class RateLimitHandler:
def __init__(self):
self.last_request_time = {}
self.min_interval = 0.1 # 100ms = max 10 req/s
def make_request(self, url, **kwargs):
"""Make request with automatic rate limit handling"""
# Throttle requests
self._wait_if_needed()
try:
response = requests.request(**kwargs, url=url)
# Check rate limit headers
remaining = int(response.headers.get('X-RateLimit-Remaining', 999))
reset = int(response.headers.get('X-RateLimit-Reset', 0))
if remaining <= 1:
reset_time = datetime.fromtimestamp(reset)
print(f'Warning: Rate limit almost exceeded. Resets at: {reset_time}')
if response.status_code == 429:
return self._handle_rate_limit(response, url, kwargs)
response.raise_for_status()
return response
except requests.HTTPError as e:
if e.response.status_code == 429:
return self._handle_rate_limit(e.response, url, kwargs)
raise
def _handle_rate_limit(self, response, url, kwargs):
"""Handle 429 response with exponential backoff"""
retry_after = int(response.headers.get('Retry-After', 60))
reset_time = int(response.headers.get('X-RateLimit-Reset', 0))
print(f'Rate limit exceeded. Waiting {retry_after} seconds...')
# Respect Retry-After header
time.sleep(min(retry_after, 3600)) # Max 1 hour
# Retry request
return self.make_request(url, **kwargs)
def _wait_if_needed(self):
"""Ensure minimum interval between requests"""
current_time = time.time()
last_time = self.last_request_time.get('global', 0)
elapsed = current_time - last_time
if elapsed < self.min_interval:
time.sleep(self.min_interval - elapsed)
self.last_request_time['global'] = time.time()
# Usage
rate_limiter = RateLimitHandler()
# Automatic rate limit handling
response = rate_limiter.make_request(
'https://open-banking-api.paysera.com/v1/accounts',
method='GET',
headers={'Authorization': f'Bearer {token}'}
)
accounts = response.json()
# With consent-specific tracking
class ConsentRateLimiter(RateLimitHandler):
def __init__(self):
super().__init__()
self.consent_usage = {} # Track per-consent usage
def can_make_request(self, consent_id):
"""Check if consent has requests remaining today"""
today = datetime.now().date()
key = f'{consent_id}:{today}'
usage = self.consent_usage.get(key, {'count': 0, 'last_reset': today})
# Reset counter if new day
if usage['last_reset'] != today:
usage = {'count': 0, 'last_reset': today}
self.consent_usage[key] = usage
return usage['count'] < 4 # AIS limit: 4 req/day
def track_request(self, consent_id):
"""Track request usage per consent"""
today = datetime.now().date()
key = f'{consent_id}:{today}'
if key not in self.consent_usage:
self.consent_usage[key] = {'count': 0, 'last_reset': today}
self.consent_usage[key]['count'] += 1
# Usage with consent tracking
consent_limiter = ConsentRateLimiter()
if consent_limiter.can_make_request(consent_id):
response = consent_limiter.make_request(
'https://open-banking-api.paysera.com/v1/accounts',
method='GET',
headers={'Authorization': f'Bearer {token}'}
)
consent_limiter.track_request(consent_id)
else:
print('Daily consent limit reached (4 requests/day)')
<?php
class RateLimitHandler {
private $lastRequestTime = 0;
private $minInterval = 0.1; // 100ms = 10 req/s
public function makeRequest($url, $options = []) {
// Throttle requests
$this->waitIfNeeded();
$ch = curl_init($url);
curl_setopt_array($ch, $options);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HEADER, true);
$response = curl_exec($ch);
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$headers = substr($response, 0, $headerSize);
$body = substr($response, $headerSize);
$statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// Parse rate limit headers
preg_match('/X-RateLimit-Remaining: (\d+)/', $headers, $remaining);
preg_match('/X-RateLimit-Reset: (\d+)/', $headers, $reset);
preg_match('/Retry-After: (\d+)/', $headers, $retryAfter);
$remaining = isset($remaining[1]) ? (int)$remaining[1] : 999;
$reset = isset($reset[1]) ? (int)$reset[1] : 0;
if ($remaining <= 1) {
$resetTime = date('Y-m-d H:i:s', $reset);
error_log("Warning: Rate limit almost exceeded. Resets at: $resetTime");
}
if ($statusCode === 429) {
return $this->handleRateLimit($headers, $url, $options);
}
return [
'status' => $statusCode,
'headers' => $headers,
'body' => json_decode($body, true)
];
}
private function handleRateLimit($headers, $url, $options) {
preg_match('/Retry-After: (\d+)/', $headers, $retryAfter);
$wait = isset($retryAfter[1]) ? (int)$retryAfter[1] : 60;
error_log("Rate limit exceeded. Waiting $wait seconds...");
// Wait and retry
sleep(min($wait, 3600)); // Max 1 hour
return $this->makeRequest($url, $options);
}
private function waitIfNeeded() {
$currentTime = microtime(true);
$elapsed = $currentTime - $this->lastRequestTime;
if ($elapsed < $this->minInterval) {
usleep(($this->minInterval - $elapsed) * 1000000);
}
$this->lastRequestTime = microtime(true);
}
}
// Usage
$rateLimiter = new RateLimitHandler();
$response = $rateLimiter->makeRequest(
'https://open-banking-api.paysera.com/v1/accounts',
[
CURLOPT_HTTPHEADER => ["Authorization: Bearer $token"],
CURLOPT_SSLCERT => 'qwac-cert.pem',
CURLOPT_SSLKEY => 'qwac-key.pem'
]
);
$accounts = $response['body'];
// Consent-specific rate limiting
class ConsentRateLimiter extends RateLimitHandler {
private $consentUsage = [];
public function canMakeRequest($consentId) {
$today = date('Y-m-d');
$key = "$consentId:$today";
if (!isset($this->consentUsage[$key])) {
$this->consentUsage[$key] = ['count' => 0, 'date' => $today];
}
// Reset if new day
if ($this->consentUsage[$key]['date'] !== $today) {
$this->consentUsage[$key] = ['count' => 0, 'date' => $today];
}
return $this->consentUsage[$key]['count'] < 4; // AIS limit
}
public function trackRequest($consentId) {
$today = date('Y-m-d');
$key = "$consentId:$today";
if (!isset($this->consentUsage[$key])) {
$this->consentUsage[$key] = ['count' => 0, 'date' => $today];
}
$this->consentUsage[$key]['count']++;
}
}
?>
Rate Limit Summary:
| Limit Type | Value | Scope |
|---|---|---|
| AIS without user presence | 4 requests/day | Per consent |
| General rate limit | 10 requests/second | Per IP |
| Hourly limit | 1000 requests/hour | Per TPP |
| Burst limit | 20 concurrent | Per TPP |
Best Practices:
- ✅ Always respect
Retry-Afterheader - ✅ Implement exponential backoff
- ✅ Track consent usage (4 req/day for AIS)
- ✅ Use request queuing for high-traffic scenarios
- ✅ Monitor rate limit headers proactively
- ✅ Cache responses where appropriate
📖 See Performance Guide for caching strategies
4. Invalid Request Format (400 Bad Request)​
Error Code: FORMAT_ERROR, PARAMETER_NOT_SUPPORTED
Why it occurs:
- Invalid JSON structure
- Missing required fields
- Invalid field format (e.g., invalid IBAN, date format)
- Unsupported parameter values
- Field validation failed
Example Response:
{
"tppMessages": [
{
"category": "ERROR",
"code": "FORMAT_ERROR",
"path": "instructedAmount.amount",
"text": "Invalid amount format. Expected decimal string with 2 decimal places."
}
]
}
Solutions:
- JavaScript
- Python
- PHP
class RequestValidator {
static validatePayment(payment) {
const errors = [];
// Validate amount
if (!payment.instructedAmount?.amount) {
errors.push('instructedAmount.amount is required');
} else if (!/^\d+\.\d{2}$/.test(payment.instructedAmount.amount)) {
errors.push('Amount must be in format: "123.45"');
}
// Validate currency
if (!payment.instructedAmount?.currency) {
errors.push('instructedAmount.currency is required');
} else if (!/^[A-Z]{3}$/.test(payment.instructedAmount.currency)) {
errors.push('Currency must be 3-letter ISO code (e.g., "EUR")');
}
// Validate IBAN
if (!payment.creditorAccount?.iban) {
errors.push('creditorAccount.iban is required');
} else if (!this.isValidIBAN(payment.creditorAccount.iban)) {
errors.push('Invalid IBAN format');
}
// Validate creditor name
if (!payment.creditorName || payment.creditorName.length > 70) {
errors.push('creditorName required (max 70 chars)');
}
if (errors.length > 0) {
throw new ValidationError(errors);
}
return true;
}
static isValidIBAN(iban) {
// Remove spaces
iban = iban.replace(/\s/g, '').toUpperCase();
// Check format: 2 letters + 2 digits + up to 30 alphanumeric
if (!/^[A-Z]{2}\d{2}[A-Z0-9]+$/.test(iban)) {
return false;
}
// IBAN length validation (simplified)
if (iban.length < 15 || iban.length > 34) {
return false;
}
return true;
}
}
class ValidationError extends Error {
constructor(errors) {
super(errors.join('; '));
this.errors = errors;
}
}
// Usage
async function initiatePayment(paymentData) {
try {
// Validate before sending
RequestValidator.validatePayment(paymentData);
const response = await fetch('https://open-banking-api.paysera.com/v1/payments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(paymentData)
});
if (!response.ok) {
const error = await response.json();
// Handle format errors
if (error.tppMessages?.[0]?.code === 'FORMAT_ERROR') {
console.error('Format error:', error.tppMessages[0].text);
console.error('Field:', error.tppMessages[0].path);
}
throw new Error(error.tppMessages[0].text);
}
return await response.json();
} catch (error) {
if (error instanceof ValidationError) {
console.error('Validation failed:', error.errors);
}
throw error;
}
}
// Example: Correct payment format
const payment = {
instructedAmount: {
amount: "150.00", // Always string with 2 decimals
currency: "EUR" // 3-letter ISO code
},
creditorAccount: {
iban: "LT123456789012345678" // Valid IBAN
},
creditorName: "Recipient Name", // Max 70 chars
remittanceInformationUnstructured: "Payment description" // Max 140 chars
};
import re
from typing import Dict, List
class RequestValidator:
@staticmethod
def validate_payment(payment: Dict) -> List[str]:
"""Validate payment request format"""
errors = []
# Validate amount
amount = payment.get('instructedAmount', {}).get('amount')
if not amount:
errors.append('instructedAmount.amount is required')
elif not re.match(r'^\d+\.\d{2}$', str(amount)):
errors.append('Amount must be in format: "123.45"')
# Validate currency
currency = payment.get('instructedAmount', {}).get('currency')
if not currency:
errors.append('instructedAmount.currency is required')
elif not re.match(r'^[A-Z]{3}$', currency):
errors.append('Currency must be 3-letter ISO code (e.g., "EUR")')
# Validate IBAN
iban = payment.get('creditorAccount', {}).get('iban')
if not iban:
errors.append('creditorAccount.iban is required')
elif not RequestValidator.is_valid_iban(iban):
errors.append('Invalid IBAN format')
# Validate creditor name
creditor_name = payment.get('creditorName', '')
if not creditor_name:
errors.append('creditorName is required')
elif len(creditor_name) > 70:
errors.append('creditorName max 70 characters')
return errors
@staticmethod
def is_valid_iban(iban: str) -> bool:
"""Validate IBAN format"""
# Remove spaces
iban = iban.replace(' ', '').upper()
# Check format
if not re.match(r'^[A-Z]{2}\d{2}[A-Z0-9]+$', iban):
return False
# Check length
if len(iban) < 15 or len(iban) > 34:
return False
return True
# Usage
def initiate_payment(payment_data: Dict):
# Validate before sending
errors = RequestValidator.validate_payment(payment_data)
if errors:
raise ValueError(f'Validation failed: {"; ".join(errors)}')
try:
response = requests.post(
'https://open-banking-api.paysera.com/v1/payments',
json=payment_data,
headers={'Authorization': f'Bearer {token}'}
)
response.raise_for_status()
return response.json()
except requests.HTTPError as e:
if e.response.status_code == 400:
error = e.response.json()
if error['tppMessages'][0]['code'] == 'FORMAT_ERROR':
field = error['tppMessages'][0].get('path', 'unknown')
message = error['tppMessages'][0]['text']
raise ValueError(f'Format error in {field}: {message}')
raise
# Example: Correct payment format
payment = {
'instructedAmount': {
'amount': '150.00', # String with 2 decimals
'currency': 'EUR' # 3-letter ISO code
},
'creditorAccount': {
'iban': 'LT123456789012345678'
},
'creditorName': 'Recipient Name',
'remittanceInformationUnstructured': 'Payment description'
}
try:
result = initiate_payment(payment)
print(f'Payment initiated: {result["paymentId"]}')
except ValueError as e:
print(f'Validation error: {e}')
<?php
class RequestValidator {
public static function validatePayment($payment) {
$errors = [];
// Validate amount
$amount = $payment['instructedAmount']['amount'] ?? null;
if (!$amount) {
$errors[] = 'instructedAmount.amount is required';
} elseif (!preg_match('/^\d+\.\d{2}$/', $amount)) {
$errors[] = 'Amount must be in format: "123.45"';
}
// Validate currency
$currency = $payment['instructedAmount']['currency'] ?? null;
if (!$currency) {
$errors[] = 'instructedAmount.currency is required';
} elseif (!preg_match('/^[A-Z]{3}$/', $currency)) {
$errors[] = 'Currency must be 3-letter ISO code (e.g., "EUR")';
}
// Validate IBAN
$iban = $payment['creditorAccount']['iban'] ?? null;
if (!$iban) {
$errors[] = 'creditorAccount.iban is required';
} elseif (!self::isValidIBAN($iban)) {
$errors[] = 'Invalid IBAN format';
}
// Validate creditor name
$creditorName = $payment['creditorName'] ?? '';
if (empty($creditorName)) {
$errors[] = 'creditorName is required';
} elseif (strlen($creditorName) > 70) {
$errors[] = 'creditorName max 70 characters';
}
if (!empty($errors)) {
throw new ValidationException(implode('; ', $errors), $errors);
}
return true;
}
public static function isValidIBAN($iban) {
// Remove spaces
$iban = str_replace(' ', '', strtoupper($iban));
// Check format
if (!preg_match('/^[A-Z]{2}\d{2}[A-Z0-9]+$/', $iban)) {
return false;
}
// Check length
if (strlen($iban) < 15 || strlen($iban) > 34) {
return false;
}
return true;
}
}
class ValidationException extends Exception {
private $errors;
public function __construct($message, $errors = []) {
parent::__construct($message);
$this->errors = $errors;
}
public function getErrors() {
return $this->errors;
}
}
// Usage
function initiatePayment($paymentData) {
global $token;
try {
// Validate before sending
RequestValidator::validatePayment($paymentData);
$ch = curl_init('https://open-banking-api.paysera.com/v1/payments');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($paymentData));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
"Authorization: Bearer $token"
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($statusCode === 400) {
$error = json_decode($response, true);
if ($error['tppMessages'][0]['code'] === 'FORMAT_ERROR') {
$field = $error['tppMessages'][0]['path'] ?? 'unknown';
$message = $error['tppMessages'][0]['text'];
throw new Exception("Format error in $field: $message");
}
}
return json_decode($response, true);
} catch (ValidationException $e) {
error_log('Validation failed: ' . $e->getMessage());
throw $e;
}
}
// Example: Correct payment format
$payment = [
'instructedAmount' => [
'amount' => '150.00', // String with 2 decimals
'currency' => 'EUR' // 3-letter ISO code
],
'creditorAccount' => [
'iban' => 'LT123456789012345678'
],
'creditorName' => 'Recipient Name',
'remittanceInformationUnstructured' => 'Payment description'
];
try {
$result = initiatePayment($payment);
echo "Payment initiated: " . $result['paymentId'] . "\n";
} catch (ValidationException $e) {
echo "Validation error: " . $e->getMessage() . "\n";
}
?>
Common Format Requirements:
| Field | Format | Example |
|---|---|---|
| Amount | String with 2 decimals | "150.00" |
| Currency | 3-letter ISO 4217 | "EUR" |
| IBAN | 15-34 alphanumeric | "LT123456789012345678" |
| Date | ISO 8601 | "2026-02-10" |
| DateTime | ISO 8601 with timezone | "2026-02-10T10:30:00Z" |
Best Practices:
- ✅ Validate requests client-side before sending
- ✅ Use schema validation libraries
- ✅ Handle validation errors gracefully
- ✅ Log validation failures for debugging
- ✅ Provide clear error messages to users
5. Insufficient Permissions (403 Forbidden)​
Error Code: CONSENT_INVALID, RESOURCE_FORBIDDEN
Why it occurs:
- Consent doesn't include requested resource
- Accessing account not listed in consent
- Requesting data outside consent scope
- Consent status is not "valid"
Example Response:
{
"tppMessages": [
{
"category": "ERROR",
"code": "CONSENT_INVALID",
"text": "The consent does not grant access to this resource",
"path": "/v1/accounts/987654321"
}
]
}
Solutions:
Check consent scope before making requests:
// Verify consent covers requested accounts
async function canAccessAccount(consentId, accountId) {
const consent = await apiClient.get(`/v1/consents/${consentId}`);
// Check if specific accounts listed
if (consent.access.accounts) {
return consent.access.accounts.includes(accountId);
}
// Check if all accounts allowed
if (consent.access.allPsd2 === 'allAccounts') {
return true;
}
return false;
}
Best Practices:
- ✅ Request appropriate consent scope upfront
- ✅ Verify consent includes needed resources
- ✅ Handle 403 by re-requesting consent with correct scope
- ✅ Inform users why additional permissions needed
6. Resource Not Found (404 Not Found)​
Error Code: RESOURCE_UNKNOWN
Why it occurs:
- Invalid account ID
- Invalid payment ID
- Invalid consent ID
- Resource has been deleted
Example Response:
{
"tppMessages": [
{
"category": "ERROR",
"code": "RESOURCE_UNKNOWN",
"text": "The requested resource does not exist",
"path": "/v1/accounts/invalid-account-id"
}
]
}
Solutions:
async function getAccountSafely(accountId) {
try {
return await apiClient.get(`/v1/accounts/${accountId}`);
} catch (error) {
if (error.response?.status === 404) {
// Resource doesn't exist
console.log(`Account ${accountId} not found`);
// Fetch account list to verify
const accounts = await apiClient.get('/v1/accounts');
return accounts.accounts[0]; // Use first available
}
throw error;
}
}
Best Practices:
- ✅ Validate resource IDs before requests
- ✅ Fetch account list to verify available resources
- ✅ Handle deleted resources gracefully
- ✅ Cache valid resource IDs
7. Internal Server Error (500)​
Error Code: INTERNAL_SERVER_ERROR
Why it occurs:
- Unexpected server-side error
- Database connection issue
- Third-party service failure
- Timeout processing request
Example Response:
{
"tppMessages": [
{
"category": "ERROR",
"code": "INTERNAL_SERVER_ERROR",
"text": "An internal server error occurred. Please try again later."
}
]
}
Solutions:
Implement retry logic with exponential backoff:
- JavaScript
- Python
- PHP
async function retryWithBackoff(fn, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (error.response?.status === 500 && attempt < maxRetries - 1) {
// Exponential backoff: 1s, 2s, 4s
const delay = Math.pow(2, attempt) * 1000;
console.log(`Retry attempt ${attempt + 1} after ${delay}ms`);
await new Promise(resolve => setTimeout(resolve, delay));
} else {
throw error;
}
}
}
}
// Usage
const accounts = await retryWithBackoff(() =>
apiClient.get('/v1/accounts')
);
import time
from functools import wraps
def retry_with_backoff(max_retries=3):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(max_retries):
try:
return func(*args, **kwargs)
except requests.HTTPError as e:
if e.response.status_code == 500 and attempt < max_retries - 1:
delay = 2 ** attempt # Exponential: 1s, 2s, 4s
print(f'Retry attempt {attempt + 1} after {delay}s')
time.sleep(delay)
else:
raise
return wrapper
return decorator
# Usage
@retry_with_backoff(max_retries=3)
def fetch_accounts():
return api_client.get('/v1/accounts')
accounts = fetch_accounts()
<?php
function retryWithBackoff($fn, $maxRetries = 3) {
for ($attempt = 0; $attempt < $maxRetries; $attempt++) {
try {
return $fn();
} catch (Exception $e) {
if ($e->getCode() === 500 && $attempt < $maxRetries - 1) {
$delay = pow(2, $attempt); // Exponential: 1s, 2s, 4s
error_log("Retry attempt " . ($attempt + 1) . " after {$delay}s");
sleep($delay);
} else {
throw $e;
}
}
}
}
// Usage
$accounts = retryWithBackoff(function() use ($apiClient) {
return $apiClient->get('/v1/accounts');
});
?>
Best Practices:
- ✅ Retry 500 errors with exponential backoff
- ✅ Log server errors for monitoring
- ✅ Set maximum retry attempts (3-5)
- ✅ Notify users if persistent failures
- ✅ Implement circuit breaker for repeated failures
8. Service Unavailable (503)​
Error Code: SERVICE_UNAVAILABLE
Why it occurs:
- Scheduled maintenance
- Server overload
- Temporary outage
- Bank's core banking system unavailable
Example Response:
{
"tppMessages": [
{
"category": "ERROR",
"code": "SERVICE_UNAVAILABLE",
"text": "The service is temporarily unavailable. Please try again later."
}
]
}
Solutions:
async function handleServiceUnavailable(fn) {
try {
return await fn();
} catch (error) {
if (error.response?.status === 503) {
const retryAfter = error.response.headers['retry-after'] || 60;
console.log(`Service unavailable. Retry after ${retryAfter}s`);
// Show maintenance message to user
showMaintenanceMessage(`Service temporarily unavailable. Please try again in ${retryAfter} seconds.`);
// Optionally schedule retry
setTimeout(() => {
handleServiceUnavailable(fn);
}, retryAfter * 1000);
}
throw error;
}
}
Best Practices:
- ✅ Respect Retry-After header
- ✅ Inform users about service unavailability
- ✅ Implement graceful degradation
- ✅ Queue operations for retry when service recovers
Error Handling Strategies​
Strategy 1: Retry with Exponential Backoff​
For transient errors (500, 503, network timeouts):
class RetryStrategy {
async executeWithRetry(fn, options = {}) {
const {
maxRetries = 3,
baseDelay = 1000,
maxDelay = 30000,
retryableStatuses = [408, 429, 500, 502, 503, 504]
} = options;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
const shouldRetry =
attempt < maxRetries &&
retryableStatuses.includes(error.response?.status);
if (!shouldRetry) throw error;
// Exponential backoff with jitter
const delay = Math.min(
baseDelay * Math.pow(2, attempt) + Math.random() * 1000,
maxDelay
);
console.log(`Attempt ${attempt + 1} failed. Retrying in ${delay}ms...`);
await this.sleep(delay);
}
}
}
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
Strategy 2: Circuit Breaker Pattern​
Prevent cascading failures:
class CircuitBreaker {
constructor(threshold = 5, timeout = 60000) {
this.failureCount = 0;
this.threshold = threshold;
this.timeout = timeout;
this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN
this.nextAttempt = Date.now();
}
async execute(fn) {
if (this.state === 'OPEN') {
if (Date.now() < this.nextAttempt) {
throw new Error('Circuit breaker is OPEN');
}
this.state = 'HALF_OPEN';
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
onSuccess() {
this.failureCount = 0;
this.state = 'CLOSED';
}
onFailure() {
this.failureCount++;
if (this.failureCount >= this.threshold) {
this.state = 'OPEN';
this.nextAttempt = Date.now() + this.timeout;
console.log(`Circuit breaker opened. Retry after ${this.timeout}ms`);
}
}
}
// Usage
const breaker = new CircuitBreaker(5, 60000);
async function fetchAccounts() {
return await breaker.execute(() =>
apiClient.get('/v1/accounts')
);
}
Strategy 3: Graceful Degradation​
Provide fallback when service unavailable:
async function getAccountsWithFallback() {
try {
// Try real-time data
return await apiClient.get('/v1/accounts');
} catch (error) {
if (error.response?.status >= 500) {
// Use cached data as fallback
const cached = await cache.get('accounts');
if (cached) {
console.log('Using cached data due to service unavailability');
return cached;
}
}
throw error;
}
}
Error Code Reference​
| HTTP Status | Error Code | Description | Retryable? | Solution |
|---|---|---|---|---|
| 400 | FORMAT_ERROR | Invalid request format | No | Fix request format |
| 400 | PARAMETER_NOT_SUPPORTED | Unsupported parameter | No | Remove unsupported parameter |
| 401 | TOKEN_INVALID | Invalid/expired token | Yes | Refresh access token |
| 401 | TOKEN_EXPIRED | Access token expired | Yes | Request new token |
| 401 | CONSENT_EXPIRED | Consent expired | No | Create new consent |
| 401 | CERTIFICATE_INVALID | Invalid certificate | No | Verify certificate validity |
| 401 | CERTIFICATE_EXPIRED | Certificate expired | No | Renew certificate |
| 403 | CONSENT_INVALID | Invalid consent | No | Request correct consent scope |
| 403 | RESOURCE_FORBIDDEN | No access to resource | No | Verify consent permissions |
| 404 | RESOURCE_UNKNOWN | Resource not found | No | Verify resource ID |
| 408 | REQUEST_TIMEOUT | Request timeout | Yes | Retry with backoff |
| 409 | RESOURCE_CONFLICT | Resource conflict | No | Handle duplicate |
| 429 | ACCESS_EXCEEDED | Rate limit exceeded | Yes | Respect Retry-After |
| 500 | INTERNAL_SERVER_ERROR | Server error | Yes | Retry with backoff |
| 503 | SERVICE_UNAVAILABLE | Service down | Yes | Retry after delay |
Related Documentation​
- 📖 FAQ - Troubleshooting
- 📖 Authentication
- 📖 Security Guidelines
- 📖 Performance Optimization
- 📖 API Endpoints
Support​
Need help with complex integrations?
Contact: tech_support@paysera.com