On this page, you’ll find recommended practices for implementing bank transfers in your application, covering security aspects, user experience, error handling, and regulatory compliance.
Two-step flow
The two-step flow (initiate → process) offers important advantages:
Display the fee before confirmation
Allowing the user to review the fee before confirming avoids surprises and reduces complaints.
Confirmation example
┌─────────────────────────────────────────────────┐
│ Confirm transfer │
│ │
│ Recipient: Maria Silva │
│ Bank: Bradesco (237) │
│ │
│ Amount: R$ 1,500.00 │
│ Fee: R$ 8.50 │
│ ───────────────────────── │
│ Total: R$ 1,508.50 │
│ │
│ [ Cancel ] [ Confirm ] │
└─────────────────────────────────────────────────┘
Use expiration to your advantage
Initiation expires in 24 hours. This allows you to:
- Save user intent without committing funds
- Resume the flow if user abandons the screen
- Validate data without balance impact
Time handling
Validate time on the client
Before calling the API, check if you’re within operating hours:
function isWithinOperatingHours() {
const now = new Date();
const brasiliaOffset = -3 * 60; // UTC-3
const utc = now.getTime() + now.getTimezoneOffset() * 60000;
const brasilia = new Date(utc + brasiliaOffset * 60000);
const day = brasilia.getDay();
const hour = brasilia.getHours();
const minute = brasilia.getMinutes();
const time = hour * 60 + minute;
// Mon-Fri (1-5), 06:30-17:00
const isWeekday = day >= 1 && day <= 5;
const isWithinHours = time >= 390 && time <= 1020; // 6:30=390, 17:00=1020
return isWeekday && isWithinHours;
}
Communicate clearly to user
If outside operating hours, inform when the transfer can be made:
TED available Monday to Friday, 06:30 to 17:00.
Next available time: Monday, 06:30.
Consider holidays
The plugin doesn’t validate bank holidays, so keep an updated calendar.
Example only — must be updated. The bankHolidays2026 array below is illustrative. In production, load holiday dates from an official calendar or configuration source at runtime (e.g., via config file, API, or locale-aware library) rather than hardcoding.
// EXAMPLE ONLY — Do not use in production without updating
// Load holiday dates from an official calendar or configuration source at runtime
const bankHolidays2026 = [
'2026-01-01', // New Year
'2026-02-16', // Carnival
'2026-02-17', // Carnival
'2026-04-03', // Good Friday
'2026-04-21', // Tiradentes
'2026-05-01', // Labor Day
'2026-06-04', // Corpus Christi
'2026-09-07', // Independence Day
'2026-10-12', // Our Lady of Aparecida
'2026-11-02', // All Souls' Day
'2026-11-15', // Republic Proclamation
'2026-12-25', // Christmas
];
Idempotency
Use idempotency keys
For write operations, always send the X-Idempotency-Key header:
POST /v1/transfers/initiate
X-Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7
This protects against:
- User double-click
- Automatic retry on timeout
- Network failures that mask success responses
Generate keys on client
Generate the idempotency key before making the request and store it until confirming the result:
async function initiateTransfer(data) {
const idempotencyKey = crypto.randomUUID();
// Save key locally
await localStorage.setItem(`transfer:${data.senderAccountId}`, idempotencyKey);
try {
const response = await api.post('/v1/transfers/initiate', data, {
headers: { 'X-Idempotency-Key': idempotencyKey },
});
return response.data;
} catch (error) {
if (error.code === 'NETWORK_ERROR') {
// Can resend with same key
return retryWithSameKey(data, idempotencyKey);
}
throw error;
}
}
Error handling
Map errors to friendly messages
const errorMessages = {
'BTF-0010': 'Transfers available Monday to Friday, 06:30 to 17:00.',
'BTF-0011': 'You have reached the daily transfer limit.',
'BTF-0012': 'An identical transfer was sent a few seconds ago.',
'BTF-2001': 'Insufficient balance for this transfer.',
'BTF-0500': 'Destination account not found. Please check the details.',
'BTF-1000': 'Service temporarily unavailable. Please try again in a few minutes.',
};
function getUserMessage(error) {
return errorMessages[error.code] || 'An error occurred. Please try again.';
}
Differentiate recoverable errors
| Code | Recoverable | Action |
|---|
BTF-0001 | No | Fix data and resubmit |
BTF-0010 | Yes | Wait for operating hours |
BTF-0011 | Yes | Wait for limit reset (midnight) |
BTF-0012 | No | Wait or use different combination |
BTF-2001 | Yes | Deposit funds and try again |
BTF-1000 | Yes | Retry with exponential backoff |
Implement smart retry
async function withRetry(fn, maxAttempts = 3) {
const retryableCodes = ['BTF-1000', 'BTF-0501', 'BTF-2000', 'BTF-3000'];
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
const isRetryable = retryableCodes.includes(error.code);
const isLastAttempt = attempt === maxAttempts;
if (!isRetryable || isLastAttempt) {
throw error;
}
const delay = Math.pow(2, attempt) * 1000; // 2s, 4s, 8s
await sleep(delay);
}
}
}
Security
Encryption in transit
All communications use TLS 1.2+ (TLS 1.3 preferred):
- JD Consultores SPB (SOAP over HTTPS)
- Midaz Ledger (gRPC with TLS)
- CRM and Plugin Fees (HTTPS)
- Webhooks (HTTPS required)
Encryption at rest
Sensitive data is encrypted with AES-256-GCM:
- JD credentials
- PII (name, CPF/CNPJ, bank details)
- Webhook secrets
Validate data on server
Never rely solely on client-side validation:
func validateRecipient(r RecipientDetails) error {
// ISPB: exactly 8 digits
if !regexp.MustCompile(`^\d{8}$`).MatchString(r.ISPB) {
return ErrInvalidISPB
}
// Document: CPF (11) or CNPJ (14)
if !isValidCPF(r.Document) && !isValidCNPJ(r.Document) {
return ErrInvalidDocument
}
// Amount: positive, max 2 decimal places
if r.Amount <= 0 || !hasMaxTwoDecimals(r.Amount) {
return ErrInvalidAmount
}
return nil
}
Protect sensitive data
- Don’t log CPF/CNPJ in plain text
- Use keyed HMAC-SHA256 or per-record salted hashes (with proper salt management) for identification in logs — avoid unsalted hashes
- These outputs must be treated as pseudonymized PII and protected accordingly
- Rotate and secure HMAC keys and salts regularly
- Mask data in interface:
***.***.***-00
- Never log raw identifiers in functions or components that produce logs
Validate webhooks
Always verify HMAC-SHA256 signature before processing:
app.post('/webhooks/ted', (req, res) => {
if (!validateSignature(req)) {
logger.warn('Invalid webhook signature', { ip: req.ip });
return res.status(401).send('Unauthorized');
}
// ...
});
Fraud prevention
Duplicate detection
The plugin implements configurable deduplication window (default: 60 seconds):
- Key:
SHA256(organizationId + senderAccountId + recipient + amount)
- Storage: Redis with configurable TTL
- Action: Duplicate requests return
409 Conflict
Rate limiting
Protection against abuse per tenant:
| Configuration | Default value |
|---|
| Algorithm | Token Bucket |
| Limit | 100 requests/minute per organization |
| Response | 429 Too Many Requests with Retry-After header |
Multi-tenant isolation
All queries are filtered by organization_id, ensuring a tenant never accesses another’s data. Redis cache also uses per-tenant prefixes.
User experience
Show progress
For TED OUT, keep the user informed:
[✓] Transfer initiated
[✓] Funds reserved
[○] Awaiting bank confirmation
[ ] Completed
Use webhooks for real-time updates
Configure webhooks and use WebSocket or push notifications to update the interface:
// Server receives webhook
socket.to(transfer.userId).emit('transfer:updated', {
transferId: transfer.id,
status: 'COMPLETED',
});
// Client updates UI
socket.on('transfer:updated', (data) => {
updateTransferStatus(data.transferId, data.status);
});
Offer receipt
After completion, allow receipt download with:
- Transfer date and time
- Sender and recipient data
- Amount and fee
- Confirmation number
- Control code (for TED)
Reconciliation
Use consistent identifiers
| Field | Usage |
|---|
transferId | Internal identifier (Lerian) |
confirmationNumber | User-readable number |
controlNumber | SPB tracking (JD) |
Maintain local history
Periodically sync with API to ensure consistency:
async function syncTransfers() {
const lastSync = await getLastSyncTimestamp();
const transfers = await api.get('/v1/transfers', {
params: { fromDate: lastSync },
});
for (const transfer of transfers.data) {
await upsertLocalTransfer(transfer);
}
await setLastSyncTimestamp(new Date());
}
Limits and quotas
Monitor limit usage
Check current usage before initiating transfers:
async function checkLimits(accountId, amount) {
const limits = await api.get(`/accounts/${accountId}/limits`);
if (limits.dailyUsed + amount > limits.dailyLimit) {
return {
allowed: false,
reason: 'daily_limit',
available: limits.dailyLimit - limits.dailyUsed,
};
}
return { allowed: true };
}
Communicate limits proactively
Daily limit: R$ 50,000.00
Used today: R$ 45,000.00
Available: R$ 5,000.00
BACEN compliance
Operating hours
The system automatically validates BACEN hours for TED:
- Business days: Monday to Friday
- Hours: 06:30 to 17:00 (Brasília time, UTC-3)
- Holidays: System queries national holiday calendar
P2P transfers are not subject to this restriction and work 24/7.
Transfer limits
The plugin respects limits configured per account and per organization:
- Per-transaction limit: Configurable per tenant (default: R$ 50,000)
- Daily limit: Sum of all transfers for the day
- Monthly limit: Aggregate volume control
Audit and traceability
Every state change is recorded in the TransferStatusHistory table:
- Previous and new status: Complete transition
- Timestamp: Exact date and time of change
- Reason: Transition reason (especially for errors)
- Author: System, user, or administrator
Mandatory retention: Data kept for 5 years per BACEN Resolution 4.753/2019.
Implement audit trail
Record all transfer-related actions:
{
"timestamp": "2026-01-21T14:30:00-03:00",
"action": "TRANSFER_INITIATED",
"userId": "user-123",
"transferId": "transfer-456",
"ip": "192.168.1.1",
"userAgent": "Mozilla/5.0..."
}
LGPD compliance
Data minimization
Collect only strictly necessary data. CPF/CNPJ are encrypted and used only for validation and compliance.
Right to be forgotten
Administrative endpoint for personal data anonymization:
DELETE /admin/v1/transfers/{id}/anonymize
This endpoint replaces personal data with generic values (e.g., “ANONYMIZED”), maintaining transactional integrity for audit.
Limited retention
| Data type | Retention period |
|---|
| Transaction data | 5 years (BACEN requirement) |
| Application logs | 90 days |
| Audit data | 5 years (anonymized after 2 years) |
Certifications and standards
The plugin was developed following industry best practices:
- OWASP Top 10: Mitigation of common vulnerabilities
- CIS Benchmarks: Secure infrastructure configuration
- ISO 27001: Information security management practices
- SOC 2 Type II: Security, availability, and confidentiality controls
Lerian follows SOC 2 Type II and ISO 27001 practices. For current certification status and audit reports, contact Lerian directly.