Skip to main content

Error Handling & Solutions

Comprehensive guide to understanding and handling errors in Paysera Open Banking API.

Overview​

When errors occur, the Open Banking API returns:

  1. HTTP status code (400-5xx) indicating the error type
  2. Structured error response with detailed information
  3. Error codes following Berlin Group specification
  4. 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 WARNING
  • code - Berlin Group error code
  • text - Human-readable description
  • path - (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:

# 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

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

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:

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;
}
}

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:

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
);

Rate Limit Summary:

Limit TypeValueScope
AIS without user presence4 requests/dayPer consent
General rate limit10 requests/secondPer IP
Hourly limit1000 requests/hourPer TPP
Burst limit20 concurrentPer TPP

Best Practices:

  • ✅ Always respect Retry-After header
  • ✅ 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:

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
};

Common Format Requirements:

FieldFormatExample
AmountString with 2 decimals"150.00"
Currency3-letter ISO 4217"EUR"
IBAN15-34 alphanumeric"LT123456789012345678"
DateISO 8601"2026-02-10"
DateTimeISO 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:

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')
);

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 StatusError CodeDescriptionRetryable?Solution
400FORMAT_ERRORInvalid request formatNoFix request format
400PARAMETER_NOT_SUPPORTEDUnsupported parameterNoRemove unsupported parameter
401TOKEN_INVALIDInvalid/expired tokenYesRefresh access token
401TOKEN_EXPIREDAccess token expiredYesRequest new token
401CONSENT_EXPIREDConsent expiredNoCreate new consent
401CERTIFICATE_INVALIDInvalid certificateNoVerify certificate validity
401CERTIFICATE_EXPIREDCertificate expiredNoRenew certificate
403CONSENT_INVALIDInvalid consentNoRequest correct consent scope
403RESOURCE_FORBIDDENNo access to resourceNoVerify consent permissions
404RESOURCE_UNKNOWNResource not foundNoVerify resource ID
408REQUEST_TIMEOUTRequest timeoutYesRetry with backoff
409RESOURCE_CONFLICTResource conflictNoHandle duplicate
429ACCESS_EXCEEDEDRate limit exceededYesRespect Retry-After
500INTERNAL_SERVER_ERRORServer errorYesRetry with backoff
503SERVICE_UNAVAILABLEService downYesRetry after delay


Support​

Need help with complex integrations?

Contact: tech_support@paysera.com