Skip to content

Authentication

The Minha Konta external API uses a security model with three main authentication layers -- API Key + Secret, per-request HMAC-SHA512 signature (only on POST) and mandatory IP whitelist -- complemented by content validation, rate limiting and idempotency.

Validation Overview

The HTTP request passes through the validations below, in this order. Any rejection returns an error before business logic runs:

POST /api/external/...

  ├─ 1. Content-Type ──────── Not application/json or multipart/form-data? → 415
  ├─ 2. X-Key-Case (KeyCase) ─ Converts params/response snake_case ↔ camelCase (optional)
  ├─ 3. API Key + Secret ───── Missing/invalid credentials? → 401 | Inactive API Key? → 401 | Expired API Key? → 401
  ├─ 4. IP Whitelist ───────── Empty whitelist? → 403 "ip whitelist required" | IP not allowed? → 403 "ip not allowed" | Inactive account? → 403
  ├─ 5. HMAC-SHA512 (POST) ─── Missing `hmac` header? → 401 | Invalid signature? → 401 | Invalid body? → 400 | API Key without secret? → 403
  ├─ 6. Rate Limiting ─── More than 90,000 req/min per IP? → 429 Retry-After: 60
  ├─ 7. Idempotency (POST) ─── Key > 256 chars? → 400 | Replay within 24h? → returns cached body + X-Idempotent-Replay: true
  └─ 8. API Key permission ───── API Key lacks the permission required by the route? → 403

       └─ Request accepted → Business logic

Validations by HTTP method

  • GET /balance uses only Content-Type → KeyCase → API Key + Secret → IP Whitelist → API Key permission -- no rate limiter, no HMAC, no idempotency (high-frequency polling is authorized).
  • Other GET / DELETE use Content-Type → KeyCase → API Key + Secret → IP Whitelist → Rate Limiter → API Key permission -- without HMAC and without idempotency.
  • POST uses the full validation flow above (all validations).

Layer 1 -- API Key + Secret

All requests must include the Authorization header. The API accepts two equivalent formats -- the native ApiKey scheme or HTTP Basic Authentication. Both are validated by the same authentication layer and have the same behavior. Choose whichever is more convenient for your HTTP client.

Authorization: ApiKey {client_id}:{client_secret}

Alternative format -- HTTP Basic

Authorization: Basic {base64(client_id:client_secret)}

Example in Bash:

bash
CLIENT_ID="cli_a1b2c3d4e5f6"
CLIENT_SECRET="sk_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef01"

# Equivalent to Authorization: ApiKey cli_a1b2...:sk_0123...
BASIC=$(printf '%s:%s' "$CLIENT_ID" "$CLIENT_SECRET" | base64)
curl -X GET https://api.minhakonta.com/api/external/balance \
  -H "Authorization: Basic $BASIC"

When to use Basic vs ApiKey

Use Basic if your HTTP client (library, gateway, proxy) already builds credentials via base64 automatically (nearly all do). Use ApiKey if you prefer to send the secret as plain text inside the header -- same API validation.

Credential fields

ComponentDescriptionPrefix
client_idPublic API Key identifiercli_
client_secretSecret key (we only store the hash)sk_

The secret is never stored in plain text. When a request arrives, the submitted secret is compared against the stored hash. If it does not match, the request is rejected before reaching business logic.

The API Key can expire

Even though in practice most API Keys are created without an expiration date, the expires_at field may exist on the credential. If a key is configured with expires_at in the past, authentication fails with 401 API key has expired. It is also possible to revoke (mark as inactive): returns 401 API key is inactive. This replaces the previous information in this documentation that stated API Keys were permanent.

Layer 2 -- HMAC-SHA512

Transactional requests (POST, PUT, PATCH) require HMAC-SHA512 signature of the body in the hmac header. The validation uses constant-time comparison to prevent timing attacks.

See HMAC-SHA512 for implementation examples in 6 languages.

Layer 3 -- IP Whitelist

Every API Key must have at least one IP in the whitelist -- even a freshly created API Key with valid credentials is rejected while the whitelist is empty:

json
{
  "error": {
    "status": 403,
    "message": "IP whitelist required. Configure at least one allowed IP to use this API key."
  }
}

Once the whitelist has at least one entry, requests from IPs outside the list receive:

json
{
  "error": {
    "status": 403,
    "message": "Request IP not in API key whitelist"
  }
}

Accepted formats

FormatExampleComment
Individual IPv4203.0.113.45Only one public endpoint
CIDR notation (IPv4)203.0.113.0/24Full /24 subnet (256 IPs). Use /32 for a single host
Aggregated CIDR172.20.16.0/20Private range (example) -- accepted literally

IPv4 vs IPv6

The API normalizes ::ffff:A.B.C.D addresses (IPv4 mapped in IPv6, used by trusted load balancers) to the corresponding IPv4 address before comparing against the whitelist. You do not need to include the IPv6-mapped form; simply register the plain IPv4. For clients that egress exclusively via IPv6, register the full IPv6 address in standard notation (e.g., 2001:db8::1).

String format

The whitelist expects exact strings. A whitespace before/after, a wrong mask (/28 when the range has 256 IPs), or an IP in notation with leading zeros (203.000.113.045) silently rejects requests with no warning in the response other than the standard 403. Always validate in the Merchant Portal using a test IP before going to production.

Configure the whitelist in the Merchant Portal when creating or editing the API Key.

Headers

Mandatory headers

HeaderValueRequired
AuthorizationApiKey {client_id}:{client_secret} or Basic {base64(client_id:client_secret)}Yes -- all requests
Content-Typeapplication/json (or multipart/form-data in uploads)Yes -- POST, PUT, PATCH with body. Sending application/x-www-form-urlencoded (default of curl -d without -H) returns 415 Unsupported Media Type
hmacHMAC-SHA512 signature of the body in lowercase hexadecimalYes -- only POST in /api/external/*

Optional headers

HeaderValueEffect
Idempotency-KeyUnique key ≤ 256 chars (UUID v4 recommended)Dedupes replays within 24h. Only works on POST -- on GET/DELETE the header is silently ignored (no error)
X-Key-CasecamelCaseConverts camelCase from the request params to snake_case (input) and snake_case to camelCase in all keys of the JSON response (output). Useful for clients in JavaScript, TypeScript, or Kotlin
X-Forwarded-ForIP(s) separated by commaRespected only when the direct TCP connection comes from a trusted proxy. Ignored in direct client connections

Idempotency-Key -- server response

When you send the Idempotency-Key header, the server echoes the same value back in Idempotency-Key in the response and, if the request is a replay of one already processed in the last 24 hours (same key + same HTTP method + same path), also adds the X-Idempotent-Replay: true header and returns the cached body exactly as it was returned in the first execution (same HTTP status, same body byte-for-byte). The cache is scoped by (method, path, key) -- using the same key in different endpoints does not cause collision. Keys longer than 256 characters are rejected with 400 Idempotency-Key must be at most 256 characters. Only 2xx responses are cached -- error responses (4xx/5xx) allow retry with the same key.

X-Key-Case -- conversion to camelCase

If your stack works in camelCase (JS/TS, Kotlin, Swift), send X-Key-Case: camelCase and the API accepts the request body in camelCase (e.g., externalId, pixKey) and returns the response with keys in camelCase. Without this header, the API stays in snake_case (e.g., external_id, pix_key). The header can be sent on any /api/external/* endpoint -- it need not always be the same value per API Key.

HMAC signs the body before the internal conversion

If you send X-Key-Case: camelCase together with a POST that requires HMAC, sign the body exactly as it travels on the wire (i.e., in camelCase if that is the serialization you are using). The HMAC is computed on the server over the body as received, not over the internal snake_case form.

Complete Example

bash
CLIENT_ID="cli_a1b2c3d4e5f6"
CLIENT_SECRET="sk_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef01"

# Balance query (GET -- no HMAC)
curl -X GET https://api.minhakonta.com/api/external/balance \
  -H "Authorization: ApiKey $CLIENT_ID:$CLIENT_SECRET"

# PIX Cash-Out (POST -- with HMAC + Idempotency-Key)
# IMPORTANT: keys in alphabetical order (amount < description < pix_key < pix_key_type).
# The server reorders alphabetically before computing the expected HMAC - see /hmac.
BODY='{"amount":3000,"description":"Pagamento","pix_key":"12345678901","pix_key_type":"cpf"}'
HMAC=$(echo -n "$BODY" | openssl dgst -sha512 -hmac "$CLIENT_SECRET" | awk '{print $2}')

curl -X POST https://api.minhakonta.com/api/external/pix/cash-out \
  -H "Authorization: ApiKey $CLIENT_ID:$CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -H "hmac: $HMAC" \
  -H "Idempotency-Key: cashout-order-9876" \
  -d "$BODY"

Additional Protections

ProtectionDescription
Rate Limiting (API)90,000 req/min (1,500 req/s) per IP across all /api/external/*, except GET /balance which has no rate limit -- high-frequency polling is allowed. Limiter key: (IP, 60s window). When the limit is exceeded, the server responds 429 Too Many Requests with header Retry-After: 60. On all 2xx responses that pass through the limiter, the header x-ratelimit-remaining is attached with the number of remaining requests in the window
Rate Limiting (edge WAF)Per-API-key/IP rules at the edge WAF when configured. Operates before the API, so a 429 from this layer does not increment the API counter
Rate Limiting (auth)5 req/min on admin/merchant authentication endpoints (does not apply to /api/external/*)
DICT protection on Cash-OutSpecific to POST /pix/cash-out when resolving PIX key via DICT. When applied, returns HTTP 202 with status: "queued" and dispatches webhook pix.payout.queued while the transaction awaits automatic reprocessing. See PIX Cash Out (by key)
AWS WAFApplication firewall protecting the API with OWASP rules (XSS, SQLi, LFI, RFI, RCE)
HTTPS + TLS 1.2+Mandatory encryption on all connections
HSTSBrowsers forced to use HTTPS

Rate limiting headers on the response

Whenever a request passes the rate limiter, the response includes:

HeaderAppears inValue
x-ratelimit-remaining2xx responses (after passing the limiter)Integer: remaining requests in the current 60s window, scoped per IP
Retry-AfterOnly on 429 Too Many Requests60 (always, in seconds) -- wait before retrying

How the limiter counts "windows"

Rate limiting uses fixed 60-second windows, not sliding windows. In practice, the stable 1,500 req/s per-IP limit is the behavior clients should consider.

Why HMAC-SHA512 and not mTLS?

mTLS (mutual TLS) authenticates the connection, not the content. If the connection is authenticated, all requests pass through without individual validation.

HMAC validates each request separately. Even within a valid connection, any change in the payload causes the request to be rejected.

AspectmTLSHMAC-SHA512
ValidatesTLS channelRequest payload
ManagementX.509 certificates (issuance, rotation, revocation, CRL/OCSP)Generate pair, update, invalidate
Operational riskExpired certificates -- frequent cause of incidentsKey is a simple string
Content integrityNoYes

TLS already guarantees transport encryption. HMAC adds payload integrity and authenticity -- something that mTLS alone does not cover.

Error Responses

The API has 4 distinct error shapes

Depending on which validation layer rejects the request, the shape of the JSON body is different. Always inspect the shape before parsing the error in the client:

  1. {"error": {status, message}} -- Content-Type (415), API Key + IP Whitelist (401/403), Idempotency (400), rate limiting (429).
  2. {"error": "forbidden", "message": "..."} -- only API Key permission (403).
  3. {"worked": false, "detail": "..."} -- only HMAC validation (400, 401, 403).
  4. {"errors": {atom: "msg"}} -- business error handler (any business error 4xx/5xx).

Layer 0 -- Content-Type

Before any authentication, POST/PUT/PATCH without an accepted Content-Type (application/json or multipart/form-data) is blocked.

415 -- Unsupported Content-Type

json
{
  "error": {
    "status": 415,
    "message": "Unsupported Media Type. Expected Content-Type: application/json",
    "hint": "Add header: -H 'Content-Type: application/json'"
  }
}

Common pitfall with curl -d

curl -d '{"...":""}' URL (without -H) sends Content-Type: application/x-www-form-urlencoded by default. The API may interpret this as form-urlencoded rather than JSON. Add -H 'Content-Type: application/json' to get the expected validation behavior.

Layer 1 -- API Key + IP Whitelist

Errors for missing/invalid credentials, inactive/expired API Key, or IP outside the whitelist come in the format {"error": {status, message}}:

401 -- Missing Credentials

json
{
  "error": {
    "status": 401,
    "message": "Missing API key credentials. Use Authorization: ApiKey <client_id>:<client_secret>"
  }
}

401 -- Invalid Credentials

json
{
  "error": {
    "status": 401,
    "message": "Invalid API key credentials"
  }
}

401 -- Inactive API Key

json
{
  "error": {
    "status": 401,
    "message": "API key is inactive"
  }
}

401 -- Expired API Key

json
{
  "error": {
    "status": 401,
    "message": "API key has expired"
  }
}

403 -- Empty IP Whitelist

json
{
  "error": {
    "status": 403,
    "message": "IP whitelist required. Configure at least one allowed IP to use this API key."
  }
}

403 -- Unauthorized IP

json
{
  "error": {
    "status": 403,
    "message": "Request IP not in API key whitelist"
  }
}

403 -- Inactive Account

json
{
  "error": {
    "status": 403,
    "message": "Account is not active"
  }
}

403 -- Insufficient Permission

This 403 comes from permission validation

This error appears when the API Key is valid, the IP is authorized, but the credential does not have the permission required by the endpoint. That is why the JSON differs from credential/IP errors: it uses {"error": "forbidden", "message": "..."}.

json
{
  "error": "forbidden",
  "message": "API key lacks permission: transfer:write"
}

Layer 2 -- HMAC-SHA512 Validation

Errors emitted by HMAC validation always come in the format {"worked": false, "detail": "..."} with different HTTP codes depending on the cause:

401 -- Invalid signature or missing header

json
{
  "worked": false,
  "detail": "Invalid HMAC signature"
}
json
{
  "worked": false,
  "detail": "Missing HMAC header"
}

400 -- Missing body or invalid JSON

json
{
  "worked": false,
  "detail": "Request body is required for HMAC validation"
}
json
{
  "worked": false,
  "detail": "Request body must be valid JSON for HMAC validation"
}

403 -- API Key without HMAC secret configured

json
{
  "worked": false,
  "detail": "HMAC secret not configured for this API key"
}

Layer 3 -- Business Errors (business error handler)

After authentication, validation errors, missing parameters, not-found resources, and business rules come in the format {"errors": {atom: "msg"}}:

400 -- Bad Request

json
{
  "errors": {
    "bad_request": {
      "amount": ["is required"]
    }
  }
}

404 -- Resource Not Found

json
{
  "errors": {
    "not_found": "Transaction not found"
  }
}

401 -- Unauthorized (business rule)

json
{
  "errors": {
    "unauthorized": "invalid credentials"
  }
}

422 -- Unprocessable Entity

json
{
  "errors": {
    "unprocessable_entity": "Invalid PIX key format"
  }
}

Layer 4 -- Rate Limiting (rate limiting or edge WAF)

429 -- Rate Limit Exceeded

Body of the 429 coming from the rate limiting layer:

json
{
  "error": {
    "status": 429,
    "message": "Too many requests. Please try again later."
  }
}

Headers included in the 429 response:

HeaderValue
Retry-After60 (seconds to wait before retry)

How to react to 429

  • Exponential backoff: start at 60s (value of Retry-After), double each subsequent retry up to a reasonable ceiling (e.g., 5 min).
  • Never ignore the header: even if your client has its own retry strategy, Retry-After is the canonical source of truth for this endpoint.
  • If 429 is from the edge WAF (layer above the API), the body may have a different shape -- 429 status with Retry-After: 60 remains the standard for any layer.

Layer 5 -- Idempotency

400 -- Idempotency-Key too long

json
{
  "error": {
    "status": 400,
    "message": "Idempotency-Key must be at most 256 characters"
  }
}

Permissions

Each API Key has a list of permissions that determine which endpoints may be accessed. If the API Key lacks the required permission, the request is rejected with 403 Forbidden.

How to obtain the API Key

  1. Use the production base api.minhakonta.com
  2. Request credential issuance for the target account
  3. Provide the IPs that must be added to the whitelist
  4. Provide the required permissions (see table below)
  5. After issuance, store the client_id and client_secret securely

To edit permissions of an existing API Key, request an update to the credential permissions.

Required to send PIX

To perform PIX Cash-Out operations (sending PIX), the API Key must have the transfer:write permission. Without this permission, all send attempts return 403 Forbidden with the message API key lacks permission: transfer:write.

Minimum permissions recommended for full operation:

  • Cash-In (receive): pix:write + transfer:read
  • Cash-Out (send): transfer:write + transfer:read
  • Queries: transfer:read + account:read + statement:read
  • Webhooks: account:write + account:read

Available Permissions

PermissionDescription
pix:writeGenerate QR Code (Cash-In)
pix:readList PIX keys
transfer:writeSend PIX (Cash-Out)
transfer:readQuery transactions (by ID, E2E, Tag, External ID), receipt, list transactions
payment:writeRequest refund and submit MED defense
payment:readList and query MED
account:writeCreate and remove webhooks
account:readQuery balance, list webhooks, validate CPF
statement:readQuery statement

Permissions per Endpoint

EndpointMethodPermission
/pix/cash-inPOSTpix:write
/pix/cash-outPOSTtransfer:write
/pix/refundPOSTpayment:write
/med/:id/defensePOSTpayment:write
/cpf/validatePOSTaccount:read
/webhooksPOSTaccount:write
/webhooksGETaccount:read
/webhooks/:idDELETEaccount:write
/balanceGETaccount:read
/transactionsGETtransfer:read
/transactions/:idGETtransfer:read
/transactions/e2e/:e2e_idGETtransfer:read
/transactions/tag/:tagGETtransfer:read
/transactions/ref/:external_idGETtransfer:read
/transactions/:id/receiptGETtransfer:read
/pix/keysGETpix:read
/medGETpayment:read
/med/:idGETpayment:read
/statementGETstatement:read

Error Response -- 403 (Insufficient Permission)

See the format in Layer 1 -- 403 Insufficient Permission.

Use payment:read to list and query MEDs. Use payment:write to submit a defense through POST /api/external/med/:id/defense. The defense endpoint receives HMAC-signed JSON; binary file uploads remain available in the Minha Konta portals.

Permissions are configured at API Key creation by the Merchant Portal or the admin API.

Security

  • Never expose the client_secret in frontend code or public repositories
  • Use environment variables on your server
  • The API Key can expire if the expires_at field is set; otherwise, it stays valid until manually revoked in the Merchant Portal
  • Configure allowed IPs in the whitelist -- an empty whitelist blocks the key with 403 IP whitelist required

Minha Konta Instituição de Pagamento - ISPB 39929224