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 logicPipeline by HTTP method
GET /balanceuses onlyContent-Type → KeyCase → API Key + Secret → IP Whitelist → RequirePermission-- no rate limiter, no HMAC, no idempotency (high-frequency polling is authorized).- Other
GET/DELETEuseContent-Type → KeyCase → API Key + Secret → IP Whitelist → Rate Limiter → RequirePermission-- without HMAC and without idempotency. POSTuses 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.
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.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
| 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 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:
{
"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 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
| 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 (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
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
| Protection | Description |
|---|---|
| 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 |
| HSTS | Browsers forced to use HTTPS |
Rate limiting headers on the response
Whenever a request crosses the rate limiter plug, 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"
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.
| 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 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:
{"error": {status, message}}--Content-Type(415),ApiKeyAuth(401/403),Idempotency(400),RateLimiter(429).{"error": "forbidden", "message": "..."}-- onlyRequirePermission(403).{"worked": false, "detail": "..."}-- onlyHmacValidation(400, 401, 403).{"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
{
"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
{
"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 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).
{
"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
{
"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 (FallbackController)
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 (plug RateLimiter or Cloud Armor)
429 -- Rate Limit Exceeded
Body of the 429 coming from the backend (plug RateLimiter):
{
"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 Cloud Armor (layer above the backend), the body may have a different shape -- 429 status with
Retry-After: 60remains the standard for any layer.
Layer 5 -- Idempotency (plug 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 register the API Key
- Access the admin panel at core.owem.com.br
- Navigate to Security → API Keys
- Click Create API Key
- Fill in the name, select the account, and add IPs to the whitelist
- Check the necessary permissions (see table below)
- Click save. The
client_idandclient_secretare 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
| 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 |
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 |
/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.
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