Account Security

Three controls the account holder configures and the API then enforces on every attempt to move money out: cashout locks, transaction PIN and visibility of who else has access to the account.

They exist for the scenario where the integrator’s credential and token are correct — and it is the account holder’s session that was taken over. Nothing here is opt-out per request: what the holder configured applies even to the credential that configured it.

Nothing changes if you do not use it

Every account starts with no lock and no PIN, and behaves exactly as it does today. The controls only apply once configured.

Cashout locks

account security locks are three independent limits:

FieldEffect
cashoutBlockedtrue rejects every outgoing payment from the account
cashoutHoursOnly allows outgoing payments inside the window (HH:MM, 24h, with timezone)
cashoutSourceIpsOnly allows outgoing payments from declared IPs/CIDRs

Read

curl "https://tenant.api.corpx.com/v1/accounts/acc-123/security/locks" \
-H "Authorization: Bearer $TOKEN" -H "X-Tenant-Id: tenant-yourcompany"
{
"accountId": "acc-123",
"current": {
"cashoutBlocked": false,
"cashoutHours": { "start": "08:00", "end": "18:00", "timezone": "America/Sao_Paulo" },
"cashoutSourceIps": ["200.10.20.30/32"]
},
"updatedAt": "2026-09-19T12:00:00Z"
}

Write

PUT takes the whole desired document (scope security_locks.manage):

curl -X PUT "https://tenant.api.corpx.com/v1/accounts/acc-123/security/locks" \
-H "Authorization: Bearer $TOKEN" \
-H "X-Tenant-Id: tenant-yourcompany" \
-H "X-Acting-Document: 12345678909" \
-H "X-Acting-Ip: 200.10.20.30" \
-H "Content-Type: application/json" \
-d '{
"cashoutBlocked": false,
"cashoutHours": { "start": "08:00", "end": "18:00", "timezone": "America/Sao_Paulo" },
"cashoutSourceIps": ["200.10.20.30/32"]
}'

Tightening applies now; loosening waits 6 hours

This is the part that changes your flow, so it is worth reading closely: the decision is made field by field.

ChangeWhen it applies
Turning cashoutBlocked onImmediately
Turning cashoutBlocked off+6h
Narrowing cashoutHours, or adding a windowImmediately
Widening or removing cashoutHours+6h
Removing an IP from cashoutSourceIpsImmediately
Adding an IP, or removing the whole list+6h

Someone who turns the block on and widens the window in the same call gets the block now and the window later. The response says exactly that:

{
"accountId": "acc-123",
"current": { "cashoutBlocked": true, "cashoutHours": { "start": "08:00", "end": "18:00" } },
"pending": { "cashoutBlocked": true, "cashoutHours": { "start": "00:00", "end": "23:59" } },
"pendingEffectiveAt": "2026-09-19T18:00:00Z",
"pendingReason": "relaxing_security_requires_grace"
}

The asymmetry is the whole design: whoever took over the account wants to turn off the block, widen the window, add their own IP — never to tighten. A symmetric grace period would protect the attacker and punish the holder who is reacting to fraud in progress.

Cancelling or bringing forward the loosening

# Cancel: discards the pending change, current keeps applying. Immediate.
curl -X DELETE ".../v1/accounts/acc-123/security/locks/pending" ...
# Bring forward: promotes the pending change now. REQUIRES the operator's PIN.
curl -X POST ".../v1/accounts/acc-123/security/locks/pending/approve" \
-H "X-Acting-Document: 12345678909" \
-H "X-Transaction-Pin: 918273" ...

Bringing it forward is the only path that shortens the grace period, which is why it costs the one thing whoever hijacked the session does not have: the PIN. Without this endpoint, the legitimate holder who wants their own cashout back would have to wait 6h with no alternative.

X-Acting-Ip

When the call comes from a server of yours on the account holder’s behalf (the internet banking case), the connection IP is your server’s, not the end user’s. cashoutSourceIps would be useless. Pass the user’s real IP in X-Acting-Ip — that is the one the lock evaluates.

The header is only accepted from integrator credentials. A delegated credential is used directly by its owner, so the connection IP is already the right one and X-Acting-Ip is ignored.

Errors

CodeHTTPWhen
cashout_locked423cashoutBlocked: true
cashout_outside_hours403Outside cashoutHours. The message states the window
cashout_source_ip_not_allowed403Source IP outside cashoutSourceIps
invalid_cashout_hours422Invalid HH:MM, start == end, or unknown timezone
no_pending_change404DELETE/approve with no scheduled loosening

Transaction PIN

The PIN belongs to the operator (identified by the document in X-Acting-Document), not to the account. An account with three authorized people has three PINs; locking one does not affect the others.

It is only required when the caller’s credential is marked for it — in practice, the internet banking credential, where a single credential serves many account holders and the token alone cannot tell who is on the other side of the screen.

Register and change

curl -X PUT "https://tenant.api.corpx.com/v1/accounts/acc-123/security/pin" \
-H "Authorization: Bearer $TOKEN" -H "X-Tenant-Id: tenant-yourcompany" \
-H "Content-Type: application/json" \
-d '{ "actingDocument": "12345678909", "pin": "918273" }'
{ "accountId": "acc-123", "actingDocument": "123.***.***-09", "status": "created" }

Changing an existing PIN requires the current one in the same body (currentPin). Without that, whoever hijacked the session would simply change the PIN and the PIN would stop protecting anything.

The scope is pin.manage. The PIN never appears in a log or a response — it is stored as an argon2id hash and the clear value is discarded.

PIN rules

RuleValue
Length6 to 12 digits
Digits onlyYes
RejectedAll digits equal (111111), sequences including wrap-around (123456, 654321, 890123), short-period patterns (121212, 123123) and a PIN contained in the holder’s birth date or document

A rejected PIN returns 422 weak_pin with the reason named.

Use

On cashout routes (PIX out, TED, boleto, internal transfer), when the credential requires a PIN:

curl -X POST ".../v1/accounts/acc-123/pix/payments" \
-H "X-Acting-Document: 12345678909" \
-H "X-Transaction-Pin: 918273" ...

Missing either one returns 428 pin_required.

Lockout

Wrong attemptsEffect
315 minute lock (429 pin_temporarily_locked, with the Retry-After header)
6Locked until reset (423 pin_locked)

A correct PIN resets the counter. To clear the 6-attempt case, whoever administers the account invalidates that operator’s PIN and they register a new one:

curl -X DELETE ".../v1/accounts/acc-123/security/pin/12345678909" ...

Verify before building the payment

curl -X POST ".../v1/accounts/acc-123/security/pin/verify" \
-H "Content-Type: application/json" \
-d '{ "actingDocument": "12345678909", "pin": "918273" }'

This exists so the UI can confirm the PIN before the payment form. Without it, the user would only find out the PIN was wrong at the end of the flow — and each mistake would burn one of the 3 attempts. A wrong attempt here counts the same: the endpoint is not a free oracle.

Operator status

curl ".../v1/accounts/acc-123/security/pin/status" ...
{
"accountId": "acc-123",
"items": [
{ "actingDocument": "123.***.***-09", "failedAttempts": 0, "locked": false,
"setAt": "2026-09-19T12:00:00Z", "updatedAt": "2026-09-19T12:00:00Z" },
{ "actingDocument": "987.***.***-21", "failedAttempts": 6, "locked": true,
"lockedUntil": null, "requiresReset": true,
"setAt": "2026-09-01T10:00:00Z", "updatedAt": "2026-09-19T14:22:00Z" }
],
"policy": { "minLength": 6, "maxLength": 12, "softLockAfter": 3,
"softLockMinutes": 15, "hardLockAfter": 6,
"digitsOnly": true, "rejectsSequential": true }
}

Documents come back masked. lockedUntil: null with requiresReset: true is the hard lock — the way out is resetting, not waiting.

Errors

CodeHTTPWhen
pin_required428Cashout route without X-Acting-Document / X-Transaction-Pin
pin_invalid403Wrong PIN. The attempt was counted
pin_not_set403That operator has no PIN on this account
pin_temporarily_locked4293 mistakes. Wait out the 15 minutes
pin_locked4236 mistakes. Requires a reset
weak_pin422PIN outside the policy
pin_not_found404DELETE for an operator with no PIN

Shared access

The same bank account can be reached by more than one tenant — the account holder hired two systems, or migrated from one to another and the old one still has access. The holder is entitled to see that:

curl ".../v1/accounts/acc-123/shared-access" \
-H "Authorization: Bearer $TOKEN" -H "X-Tenant-Id: tenant-yourcompany"
{
"accountId": "acc-123",
"items": [
{ "tenantId": "tenant-yourcompany", "displayName": "Your Company",
"status": "active", "since": "2026-01-15T10:00:00Z", "isCurrent": true },
{ "tenantId": "tenant-other", "displayName": "Other Integrator",
"status": "suspended", "since": "2025-08-02T09:30:00Z", "isCurrent": false }
]
}

Public metadata only: display name, state and since when. No credentials, volume, balance or configuration of the other tenants. status is the state of that tenant’s access to the account, not of the tenant itself — a suspended row shows up precisely so the holder can confirm a revocation took effect.

Read scope. Revoking access is a backoffice operation, not this route.

See also

  • Cashout — the outgoing routes where locks and PIN apply
  • Request Signing — the other half of the design: one credential per account
  • Errors — full catalog