Skip to main content
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 (initiateprocess) 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

CodeRecoverableAction
BTF-0001NoFix data and resubmit
BTF-0010YesWait for operating hours
BTF-0011YesWait for limit reset (midnight)
BTF-0012NoWait or use different combination
BTF-2001YesDeposit funds and try again
BTF-1000YesRetry 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:
ConfigurationDefault value
AlgorithmToken Bucket
Limit100 requests/minute per organization
Response429 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

FieldUsage
transferIdInternal identifier (Lerian)
confirmationNumberUser-readable number
controlNumberSPB 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 typeRetention period
Transaction data5 years (BACEN requirement)
Application logs90 days
Audit data5 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.