IntegrationBest practices

Best practices

Recommended patterns for pagination, error handling, retries, and more

Overview

This guide collects best practices for a reliable Conta Simples API integration. It covers pagination, errors, retries, and more — without duplicating per-endpoint details, which live in the API Reference.


Pagination

The API uses cursor-based pagination for large result sets. The nextPageStartKey field is the cursor.

How it works

First request

Send filter parameters and limit. Do not send nextPageStartKey.

Check the response

If nextPageStartKey is present and not null, more pages exist.

Next page

Send the same request with the nextPageStartKey from the previous response.

Last page

When nextPageStartKey is null or missing, you are done.

Full example

async function getAllTransactions(
  baseUrl: string,
  token: string,
  startDate: string,
  endDate: string
) {
  const allTransactions: any[] = [];
  let nextPageKey: string | undefined;

  while (true) {
    const payload: Record<string, any> = {
      startDate,
      endDate,
      limit: 100,
    };

    if (nextPageKey) {
      payload.nextPageStartKey = nextPageKey;
    }

    const queryString = new URLSearchParams(payload).toString();

    const response = await fetch(`${baseUrl}/statements/v1/credit-card?${queryString}`, {
      method: "GET",
      headers: {
        Authorization: `Bearer ${token}`,
        "Content-Type": "application/json",
      }
    });

    const data = await response.json();
    allTransactions.push(...data.transactions);

    nextPageKey = data.nextPageStartKey;
    if (!nextPageKey) break;
  }

  return allTransactions;
}

nextPageStartKey is opaque. Do not decode or modify it — use it exactly as returned.

Rules

RuleValue
Minimum per page5 items
Maximum per page100 items
Maximum range per request62 days

For longer periods, run multiple sequential queries.


Error handling

Error categories

CodeMeaningSuggested action
400Invalid parametersFix parameters and retry
401Invalid or expired token, or a refused signatureRefresh the token, sign again, and retry
403Insufficient permissionsCheck your credentials
404Not foundVerify path IDs
5xxServer errorRetry with exponential backoff

Detailed troubleshooting

Causes, examples, and fixes for each error code.

Retry with exponential backoff

For transient errors (5xx, timeout), use automatic retry:

Credential with a public key: sign every attempt again. Each attempt needs its own signature, with a new created and nonce. Build the signature headers inside the function that runs each attempt. See Request Signing.

const RETRYABLE_STATUS = new Set([500, 502, 503, 504]);

async function requestWithRetry(
  method: string,
  url: string,
  maxRetries = 3,
  // Called on every attempt: returns fresh headers, with a fresh signature when there is one.
  buildOptions: () => RequestInit = () => ({})
): Promise<Response> {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      const controller = new AbortController();
      const timeout = setTimeout(() => controller.abort(), 30_000);

      const response = await fetch(url, {
        method,
        ...buildOptions(),
        signal: controller.signal,
      });
      clearTimeout(timeout);

      if (!RETRYABLE_STATUS.has(response.status)) {
        return response;
      }
    } catch (err) {
      if (!(err instanceof DOMException && err.name === "AbortError")) throw err;
      // Treat timeout as retryable
    }

    if (attempt < maxRetries - 1) {
      const waitTime = 2 ** attempt + Math.random();
      await new Promise((resolve) => setTimeout(resolve, waitTime * 1000));
    }
  }

  throw new Error("Max retries exceeded");
}

Automatic token refresh

For 401, refresh the token and retry:

async function requestWithAuthRetry(
  method: string,
  url: string,
  // Called on every attempt: returns fresh headers, with a fresh signature when there is one.
  buildOptions: () => RequestInit & { headers: Record<string, string> }
): Promise<Response> {
  let response = await fetch(url, { method, ...buildOptions() });

  if (response.status === 401) {
    // A 401 can be an expired token or a refused signature.
    // Refreshing the token and signing again handles both cases.
    invalidateTokenCache();
    const newToken = await getFreshToken();
    const options = buildOptions();
    options.headers["Authorization"] = `Bearer ${newToken}`;
    response = await fetch(url, { method, ...options });
  }

  return response;
}

Values and dates

Money amounts

Amounts are represented as decimal numbers in Brazilian reais (BRL):

{
  "amountBrl": 150.75
}

Use decimal types in your stack (not binary floats) to avoid rounding errors in financial math.

Dates

ContextFormatExample
Request parametersYYYY-MM-DD2025-01-15
Response fieldsISO 8601 (UTC)2025-01-15T14:30:00.000Z

Response timestamps are in UTC. Convert to your app’s local timezone as needed.

International transactions

For international items, exchangeRateUsd carries the FX rate. amountBrl is already in BRL — use exchangeRateUsd only for audit context.


Downloading attachments

Transactions may list receipts in the attachments array. To download them:

  1. Read the attachment id from the transaction response
  2. GET /attachments/v1/content/{attachmentId} — see API Reference
  3. Use the response Content-Type to detect format

Supported types

Content-TypeExtensionTypical use
image/png.pngScreenshots, photos
image/jpeg.jpgPhotos, scanned receipts
application/pdf.pdfInvoices, documents

Example

import { writeFile, mkdir } from "fs/promises";
import path from "path";

async function downloadAttachment(
  baseUrl: string,
  token: string,
  attachmentId: string,
  outputDir = "./downloads"
): Promise<string> {
  const response = await fetch(`${baseUrl}/attachments/v1/content/${attachmentId}`, {
    headers: { Authorization: `Bearer ${token}` },
  });

  const contentType = response.headers.get("Content-Type") ?? "";
  const extMap: Record<string, string> = {
    "image/png": ".png",
    "image/jpeg": ".jpg",
    "application/pdf": ".pdf",
  };
  const ext = extMap[contentType] ?? ".bin";

  await mkdir(outputDir, { recursive: true });
  const filepath = path.join(outputDir, `${attachmentId}${ext}`);

  const buffer = Buffer.from(await response.arrayBuffer());
  await writeFile(filepath, buffer);

  return filepath;
}

Security

Storing credentials

  • AWS Secrets Manager
  • HashiCorp Vault
  • Azure Key Vault
  • GCP Secret Manager
  • Runtime environment variables (not in code)

The private key of a signing credential must be handled like the API secret and follows the same rules above, with two differences:

  • Conta Simples only receives the public key. Nobody at Conta Simples, support included, will ask for the private key.
  • The key cannot be changed on the credential. To use another one, create a credential with the new key, migrate the integration, and then revoke the old one.

Environment separation

Use different env vars for Sandbox and Production:

# Sandbox
export OPENAPI_BASE_URL=https://api-sandbox.contasimples.com
export OPENAPI_API_KEY={API_KEY_SANDBOX}

# Production
export OPENAPI_BASE_URL=https://api.contasimples.com
export OPENAPI_API_KEY={API_KEY_PROD}

Never use Production credentials in development or test.


Production checklist

  • Authentication with automatic token refresh
  • Pagination implemented correctly
  • Per–HTTP code error handling
  • Exponential backoff for 5xx, with a fresh signature on every attempt
  • Credentials and the private key in a secret store
  • Server clocks in sync via NTP, because a signature is valid for 300 seconds
  • Sandbox vs Production kept separate
  • Monitoring and alerts
  • Structured logging with request_id

Next steps