Skip to content

Authentication

The Owem Pay 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 -- executed inside a larger pipeline of plugs that also validate Content-Type, apply rate limiting and implement idempotency.

Pipeline Overview

The HTTP request passes through the plugs below, in this order. Any plug may interrupt the pipeline with a terminal error (halt):

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 Limiter (ETS) ─── 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. RequirePermission ───── API Key lacks the permission required by the route? → 403

       └─ Request accepted → Controller/business logic

Pipeline by HTTP method

  • GET /balance uses only Content-Type → KeyCase → API Key + Secret → IP Whitelist → RequirePermission -- 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 → RequirePermission -- without HMAC and without idempotency.
  • POST uses the full pipeline above (all 8 plugs).

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 plug (ApiKeyAuth) 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.owem.com.br/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 -- both land in the same parser on the backend.

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 exists in the schema. 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 backend normalizes ::ffff:A.B.C.D addresses (IPv4 mapped in IPv6, used by the GKE load balancer) 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 (GKE load balancer). 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.owem.com.br/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.owem.com.br/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 (backend)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 (Cloud Armor)Per-API-Key rules at the Google Cloud load balancer (typically 3,000/min per key). Configured per merchant. Operates before the backend, so a 429 from this layer does not increment the backend counter
Rate Limiting (auth)5 req/min on admin/merchant authentication endpoints (does not apply to /api/external/*)
DICT quota per-merchant (ClientLimiter)Specific to POST /pix/cash-out when resolving PIX key via DICT. Default 120 req/min per merchant. Exceeded: returns HTTP 202 with status: "queued" and dispatches webhook pix.payout.queued -- automatic retry for up to 120 min. See PIX Cash Out (by key)
Cloud Armor (WAF)Application firewall protecting the cluster 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 crosses the rate limiter plug, 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"

The limiter uses fixed 60-second windows, not sliding window. The ETS key is (IP, floor(now_ms / 60000)). This means that, in theory, a client could accumulate up to 180,000 requests in 60 real seconds by sending 90,000 at the end of one window and 90,000 at the start of the next. In practice, this burst is negligible and the stable rate of 1,500 req/s is what matters.

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 pipeline plug rejects the request, the shape of the JSON body is different. Always inspect the shape before parsing the error in the client. In order of appearance in the pipeline:

  1. {"error": {status, message}} -- Content-Type (415), ApiKeyAuth (401/403), Idempotency (400), RateLimiter (429).
  2. {"error": "forbidden", "message": "..."} -- only RequirePermission (403).
  3. {"worked": false, "detail": "..."} -- only HmacValidation (400, 401, 403).
  4. {"errors": {atom: "msg"}} -- FallbackController (any business error 4xx/5xx).

Layer 0 -- Content-Type (plug RequireJsonContentType)

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. Plug.Parsers then treats the JSON as a single form key, and the controller complains about "missing required fields". The RequireJsonContentType plug prevents this noise by returning 415 with the hint.

Layer 1 -- API Key + IP Whitelist (plug ApiKeyAuth)

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 another plug

This error is emitted by the RequirePermission plug which runs after ApiKeyAuth (API Key auth, IP whitelist check and rate limiter already passed), already inside the controller. That is why the shape of the JSON is different from the other 401/403 in this layer: uses {"error": "forbidden", "message": "..."} (string instead of object).

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

Layer 2 -- HMAC-SHA512 Validation (HMAC plug)

Errors emitted by the HmacValidation plug 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 (FallbackController)

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 (plug RateLimiter or Cloud Armor)

429 -- Rate Limit Exceeded

Body of the 429 coming from the backend (plug RateLimiter):

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 Cloud Armor (layer above the backend), the body may have a different shape -- 429 status with Retry-After: 60 remains the standard for any layer.

Layer 5 -- Idempotency (plug 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 register the API Key

  1. Access the admin panel at core.owem.com.br
  2. Navigate to Security → API Keys
  3. Click Create API Key
  4. Fill in the name, select the account, and add IPs to the whitelist
  5. Check the necessary permissions (see table below)
  6. Click save. The client_id and client_secret are displayed only once -- copy and store securely

To edit permissions of an existing API Key, click the permissions icon in the API Keys list.

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
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
/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.

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

Owem Pay Instituição de Pagamento — ISPB 37839059