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 logicValidations by HTTP method
GET /balanceuses onlyContent-Type → KeyCase → API Key + Secret → IP Whitelist → API Key permission-- no rate limiter, no HMAC, no idempotency (high-frequency polling is authorized).- Other
GET/DELETEuseContent-Type → KeyCase → API Key + Secret → IP Whitelist → Rate Limiter → API Key permission-- without HMAC and without idempotency. POSTuses 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.
Recommended format -- ApiKey scheme
Authorization: ApiKey {client_id}:{client_secret}Alternative format -- HTTP Basic
Authorization: Basic {base64(client_id:client_secret)}Example in 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
| Component | Description | Prefix |
|---|---|---|
client_id | Public API Key identifier | cli_ |
client_secret | Secret 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:
{
"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:
{
"error": {
"status": 403,
"message": "Request IP not in API key whitelist"
}
}Accepted formats
| Format | Example | Comment |
|---|---|---|
| Individual IPv4 | 203.0.113.45 | Only one public endpoint |
| CIDR notation (IPv4) | 203.0.113.0/24 | Full /24 subnet (256 IPs). Use /32 for a single host |
| Aggregated CIDR | 172.20.16.0/20 | Private 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
| Header | Value | Required |
|---|---|---|
Authorization | ApiKey {client_id}:{client_secret} or Basic {base64(client_id:client_secret)} | Yes -- all requests |
Content-Type | application/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 |
hmac | HMAC-SHA512 signature of the body in lowercase hexadecimal | Yes -- only POST in /api/external/* |
Optional headers
| Header | Value | Effect |
|---|---|---|
Idempotency-Key | Unique 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-Case | camelCase | Converts 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-For | IP(s) separated by comma | Respected 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
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
| Protection | Description |
|---|---|
| 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-Out | Specific 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 WAF | Application firewall protecting the API with OWASP rules (XSS, SQLi, LFI, RFI, RCE) |
| HTTPS + TLS 1.2+ | Mandatory encryption on all connections |
| HSTS | Browsers forced to use HTTPS |
Rate limiting headers on the response
Whenever a request passes the rate limiter, the response includes:
| Header | Appears in | Value |
|---|---|---|
x-ratelimit-remaining | 2xx responses (after passing the limiter) | Integer: remaining requests in the current 60s window, scoped per IP |
Retry-After | Only on 429 Too Many Requests | 60 (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.
| Aspect | mTLS | HMAC-SHA512 |
|---|---|---|
| Validates | TLS channel | Request payload |
| Management | X.509 certificates (issuance, rotation, revocation, CRL/OCSP) | Generate pair, update, invalidate |
| Operational risk | Expired certificates -- frequent cause of incidents | Key is a simple string |
| Content integrity | No | Yes |
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:
{"error": {status, message}}--Content-Type(415),API Key + IP Whitelist(401/403),Idempotency(400), rate limiting (429).{"error": "forbidden", "message": "..."}-- onlyAPI Key permission(403).{"worked": false, "detail": "..."}-- onlyHMAC validation(400, 401, 403).{"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
{
"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
{
"error": {
"status": 401,
"message": "Missing API key credentials. Use Authorization: ApiKey <client_id>:<client_secret>"
}
}401 -- Invalid Credentials
{
"error": {
"status": 401,
"message": "Invalid API key credentials"
}
}401 -- Inactive API Key
{
"error": {
"status": 401,
"message": "API key is inactive"
}
}401 -- Expired API Key
{
"error": {
"status": 401,
"message": "API key has expired"
}
}403 -- Empty IP Whitelist
{
"error": {
"status": 403,
"message": "IP whitelist required. Configure at least one allowed IP to use this API key."
}
}403 -- Unauthorized IP
{
"error": {
"status": 403,
"message": "Request IP not in API key whitelist"
}
}403 -- Inactive Account
{
"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": "..."}.
{
"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
{
"worked": false,
"detail": "Invalid HMAC signature"
}{
"worked": false,
"detail": "Missing HMAC header"
}400 -- Missing body or invalid JSON
{
"worked": false,
"detail": "Request body is required for HMAC validation"
}{
"worked": false,
"detail": "Request body must be valid JSON for HMAC validation"
}403 -- API Key without HMAC secret configured
{
"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
{
"errors": {
"bad_request": {
"amount": ["is required"]
}
}
}404 -- Resource Not Found
{
"errors": {
"not_found": "Transaction not found"
}
}401 -- Unauthorized (business rule)
{
"errors": {
"unauthorized": "invalid credentials"
}
}422 -- Unprocessable Entity
{
"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:
{
"error": {
"status": 429,
"message": "Too many requests. Please try again later."
}
}Headers included in the 429 response:
| Header | Value |
|---|---|
Retry-After | 60 (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-Afteris 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: 60remains the standard for any layer.
Layer 5 -- Idempotency
400 -- Idempotency-Key too long
{
"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
- Use the production base api.minhakonta.com
- Request credential issuance for the target account
- Provide the IPs that must be added to the whitelist
- Provide the required permissions (see table below)
- After issuance, store the
client_idandclient_secretsecurely
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
| Permission | Description |
|---|---|
pix:write | Generate QR Code (Cash-In) |
pix:read | List PIX keys |
transfer:write | Send PIX (Cash-Out) |
transfer:read | Query transactions (by ID, E2E, Tag, External ID), receipt, list transactions |
payment:write | Request refund and submit MED defense |
payment:read | List and query MED |
account:write | Create and remove webhooks |
account:read | Query balance, list webhooks, validate CPF |
statement:read | Query statement |
Permissions per Endpoint
| Endpoint | Method | Permission |
|---|---|---|
/pix/cash-in | POST | pix:write |
/pix/cash-out | POST | transfer:write |
/pix/refund | POST | payment:write |
/med/:id/defense | POST | payment:write |
/cpf/validate | POST | account:read |
/webhooks | POST | account:write |
/webhooks | GET | account:read |
/webhooks/:id | DELETE | account:write |
/balance | GET | account:read |
/transactions | GET | transfer:read |
/transactions/:id | GET | transfer:read |
/transactions/e2e/:e2e_id | GET | transfer:read |
/transactions/tag/:tag | GET | transfer:read |
/transactions/ref/:external_id | GET | transfer:read |
/transactions/:id/receipt | GET | transfer:read |
/pix/keys | GET | pix:read |
/med | GET | payment:read |
/med/:id | GET | payment:read |
/statement | GET | statement: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_secretin frontend code or public repositories - Use environment variables on your server
- The API Key can expire if the
expires_atfield 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
