Account Protection Guide
Protect every account action. Down to the last point.
One API, three decisions, and verifications that Koin runs end to end. With a native data model for loyalty programs.
Loyalty edition · Version 2.3 · October 2026 · English
The core integration, in two calls
POST /v1/ato/evaluations — Can this action continue?
PATCH /v1/ato/notifications/{evaluation_id} — This is how it ended.
approved · received · denied
To act on the whole account (search, block, quarantine, recover), use Account Services: /v1/accounts/… New
References
- Sandbox — api-sandbox.koin.com.br
- API Reference · Account Protection — Create Evaluation · Notifications
- API Reference · Account Services — Search · Block · Quarantine · Recovery New
- API Reference · Transactional — Create Evaluation (Ecommerce)
- For agents — llms.txt
NewNew marks what changed in this version of the guide.
1. The essentials
Whenever a user attempts something sensitive on their account, your backend asks Koin before executing it. Koin responds with one of three decisions:
| Decision | What it means | What you do |
|---|---|---|
approved | No risk signals | You continue without friction |
received | A verification is required | You show the strategy Koin indicates (token, liveness, 3DS…) |
denied | High risk or blocked account | You do not execute the action |
When the action ends, you tell Koin how it ended. That is all.
POST /v1/ato/evaluations → Can this action continue?
PATCH /v1/ato/notifications/{evaluation_id} → This is how it ended.Koin decides on its own. Its models, heuristics, and Koin's data network detect account takeover from day one, without you writing a single rule. If you want, you can customize the decision for your program (rules, lists, thresholds, and strategies per event) from the dashboard. Your integration does not change in either case.
One API, three families of endpoints
Account Protection is one product. It has several endpoints, grouped in families, and each family has its own reference. They all use the same key and the same user_id.
| Family | Endpoints | What it is for |
|---|---|---|
| Account events | POST /v1/ato/evaluations · PATCH /v1/ato/notifications/{evaluation_id} | Evaluate each sensitive account action and report how it ended. |
| Transactions | POST /v1/antifraud/evaluations | Points purchases and purchases with points. |
| Account Services New | /v1/accounts/… | Search accounts, block them, quarantine them, and recover them. |
2. Why Koin
| Koin | What is typical in the market | |
|---|---|---|
| Decides on its own, and can be customized | Koin delivers a ready decision from the first event. Each customer can adjust it to their program without touching the integration. | A score the customer has to turn into their own rules before they can use it. |
| End-to-end verification | Koin sends the token, hosts the page, validates, and reports the final decision. You can also use your own verifications. | The provider returns a score or a recommendation; you build the verification. |
| Native loyalty | Points balance, tier, beneficiaries, and mixed payments with points are part of the data model. | No provider publishes loyalty events: points are modeled as a generic field. |
| Control of the whole account New | Search, block, quarantine, and recover accounts, with your own reasons and a history for every action. One by one or in bulk. | Varies by provider. |
| Built for LatAm | Native CPF, CNPJ, DNI, CUIL, CI, and PASSPORT; WhatsApp as a channel; its own device SDK (JS, iOS, Android, Flutter). | Models designed for the US and Europe. |
| One identity across every endpoint | The same user_id links account events, transactions, and account actions: yesterday's suspicious login weighs on today's redemption. | Account events and payment events live in separate products. |
| Open documentation | Public, readable by agents (llms.txt, every page as .md). | Many providers keep it behind a login. |
3. Quickstart: your first evaluation in 3 steps
Step 1 · Authentication
Every call uses your private key as a Bearer token. The Koin team delivers it during onboarding.
Authorization: Bearer sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/jsonStep 2 · Capture the device
Integrate the device fingerprint SDK (JavaScript, iOS, Android, Flutter). The SDK generates a session_id that you send on every event.
Why it mattersThe device is the number-one signal against account takeover. A successful login from a device that has never been seen, followed by an email change, is the classic pattern of a taken-over account.
Step 3 · Evaluate a login
curl -X POST https://api-sandbox.koin.com.br/v1/ato/evaluations \
-H "Authorization: Bearer $KOIN_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "LOGIN",
"details": {
"user_id": "company_8812345",
"email": "[email protected]",
"authentication_type": "PASSWORD",
"client_result": "SUCCESS"
},
"device": {
"ipv4": "187.61.115.149",
"session_id": "455e1d2f-6db1-409a-8089-80e40cf37a12",
"merchant_device": "77370a68-b70e-4a71-a83e-222994416c12"
}
}'Response:
{
"id": "VEFRUD8HUR25N4992221",
"evaluation_id": "31ff2cc8-199e-4c3c-ba2d-4d85b88bd9f2",
"status": "approved",
"score": 12,
"analysis_type": "AUTOMATIC"
}Store the evaluation_id: you will use it to report the final result.
4. Key concepts
The whole model rests on six ideas.
| Concept | In one line | Field |
|---|---|---|
| Event | The sensitive action you send to Koin so it can evaluate it. | type |
| Verification strategy | The extra step Koin may require before letting the action proceed: token by email or WhatsApp, liveness, 3DS. Koin decides when to apply it; the customer can customize that criterion. | strategies[] |
| Authentication context | The context of a password change: with an active session, without a session, or initiated by the system. It distinguishes, for example, someone who forgot their password from someone who changes it while logged in. | authentication |
| Prior check by the customer | The result of a validation your system already performed before calling Koin, for example whether the password was correct. It is an input. | client_result |
| Final result | How the business action ended: it completed or it did not. It is reported later, separately. | PATCH New |
| Risk state | The state of the whole account: normal, quarantined, blocked, or recovering. It does not belong to a single evaluation. | risk_state New |
The prior check and the final result are not the same thing.
client_resulthappens before the evaluation ("the password was correct"). The final result happens when the whole flow ends ("the email change was completed").
5. How an evaluation works
Every event follows the same path. Verification is conditional: it happens only if Koin responds received, and either Koin or your own system can orchestrate it.
sequenceDiagram
actor User
participant Backend as Customer backend
participant Koin
User->>Backend: Starts a sensitive action
Backend->>Koin: POST /v1/ato/evaluations
Koin-->>Backend: approved, received + strategy, or denied
opt received and Koin orchestrates the strategy
User->>Koin: Completes verification on Koin's page
Koin-->>Backend: Final decision approved or denied via callback_url
end
opt received and the customer orchestrates the strategy
User->>Backend: Completes the customer's verification
Backend->>Koin: PATCH with the strategy result
Koin-->>Backend: Final decision approved or denied via callback_url
end
Backend->>User: Executes or cancels the action
Backend->>Koin: PATCH with the final result of the action
Figure 1 · Evaluation lifecycle, with the strategy orchestrated by Koin or by the customer.
The callback carries the decision, not the strategy result.When there is a verification, Koin combines its result with the rest of the signals and sends you, through
callback_url, the final decision of the evaluation:approvedordenied.
6. Decisions and strategies
The three decisions
status | Meaning | What your system does |
|---|---|---|
approved | The action may continue. | Completes the action. |
received | In progress: a verification is required. | Shows the strategy (link) or runs it with its own means, and waits for the final decision through callback_url. |
denied | Block. | Does not execute the action. If the account is blocked (by you, by Koin, or by the engine), all of its sensitive events return denied. |
On account events (
LOGIN,SIGN_UP,CHANGE_PASSWORD,CHANGE_PROFILE,CLOSE_ACCOUNT),receivedalways comes with a strategy the user resolves in the moment. It never leaves the user waiting for a manual review.
Available strategies
strategies[].type | What it validates | When it is typically used |
|---|---|---|
VerificationCode | 6-digit token by email or WhatsApp, or magic link. | Forgotten password, contact change, account closure. |
Liveness | Facial biometrics with a proof of life. | High risk: contact change after a suspicious login. |
CollectAuthRecovery | 3DS2 authentication (FRICTIONLESS or CHALLENGE). | Actions that involve a card. |
ManualVerification | Review by the risk team, held until the SLA. | High-value operations, never on login. |
Example response with a strategy:
{
"id": "12345678",
"evaluation_id": "1b943df8-e4fa-4a50-a254-67186911d66e",
"status": "received",
"score": 50,
"analysis_type": "AUTOMATIC",
"strategies": [
{
"type": "VerificationCode",
"link": "https://antifraud.koin.com.br/recovery/1b943df8-e4fa-4a50-a254-67186911d66e?language=en"
}
]
}Detail for each strategy: Strategies.
7. Event catalog
A single endpoint, POST /v1/ato/evaluations, and the type field indicates the event. Koin decides in each case whether a verification applies; the "Reference verification" column shows the usual behavior, which each customer can customize.
| Event | When you send it | When you do not | Reference verification |
|---|---|---|---|
LOGIN | The user signs in. You report the result of your own credential check (client_result). | Do not send only successful logins: failed ones feed credential-stuffing detection. | According to the risk Koin detects. |
SIGN_UP | Account creation. Requires user_id, first_name, last_name, birth_date, email, and profile (category and points_balance) New. | — | According to the risk Koin detects. |
CHANGE_PASSWORD | Any password change or creation. The authentication field indicates the context. | — | Depends on the context: typically yes when the user has no session, no when they change it while logged in. |
CHANGE_PROFILE | A piece of data is added, updated, deleted, or confirmed: email, phone, account, card, personal data, category New, or a beneficiary New. | Passwords: use CHANGE_PASSWORD. Points balance: it is not reported here (see §11). | Token when phone and/or email changes; for the rest, according to the risk Koin detects. |
CLOSE_ACCOUNT | The user asks to close their account. | — | Token, always. |
The API Reference also includes USER_ACTIVATION, GENERAL_CHECKPOINT, and ENROLLMENT, for use cases outside the scope of this guide.
Fields common to every event
| Field | Required | Why it matters |
|---|---|---|
type | Yes | Defines the event and the details schema. |
details.user_id | Yes | The identity that links every account event, in Account Protection and in the transactional API. Always use the same identifier. |
device.session_id | Recommended | Generated by the device SDK. Without it, Koin loses the strongest signal against account takeover. |
device.ipv4 / device.ipv6 | Recommended (one of the two) | Geolocation, ASN, and detection of distributed attacks. |
device.merchant_device | Optional | Your own device identifier, so you can cross-check it with Koin's. |
callback_url | Optional | HTTPS endpoint where you receive the final evaluation decision when there was a strategy. |
sla_date | Optional | How long the evaluation may stay open. |
8. Recipes by event
8.1 LOGIN
{
"type": "LOGIN",
"details": {
"user_id": "company_8812345",
"email": "[email protected]",
"authentication_type": "PASSWORD",
"client_result": "FAILED",
"captcha": true,
"document": { "type": "CPF", "number": "12345678909", "nationality": "BR" }
},
"device": {
"ipv4": "187.61.115.149",
"session_id": "455e1d2f-6db1-409a-8089-80e40cf37a12"
}
}authentication_type: PASSWORD · MAGIC_LINK · SOCIAL_NETWORK
8.2 CHANGE_PASSWORD: one event, three contexts
Every password change uses the same event. The authentication field tells Koin the context it happens in, and that is how the three use cases are distinguished.
flowchart LR
A["I forgot my password<br/>(no session)"]
E["CHANGE_PASSWORD"]
B["Change from my profile<br/>(with session)"]
C["Forced reset<br/>(support or suspicious activity)"]
R{"Koin decision"}
A -->|"authentication: unauthenticated"| E
B -->|"authentication: authenticated"| E
C -->|"authentication: system"| E
E --> R
classDef dark fill:#0E2419,color:#ffffff,stroke:#0E2419
class E dark
classDef decision fill:#E6F6EA,color:#0A7F25,stroke:#10B132
class R decision
Figure 2 · Password change: 3 contexts, 1 event.
authentication | Use case | Reference verification |
|---|---|---|
unauthenticated | I forgot my password. The user, without a session, starts the process. The previous password stays valid until it finishes. | Yes (for example, a token). |
authenticated | Change from the profile. The user, with an active session, changes their password. | Typically no. |
system | Forced reset. The system resets the password (support or suspicious activity) and the user changes it afterwards. | According to the risk Koin detects. |
The field accepts only these three values (case insensitive). If you omit it, the event is still processed, but Koin loses the context.
{
"type": "CHANGE_PASSWORD",
"details": {
"user_id": "company_8812345",
"email": "[email protected]",
"authentication": "unauthenticated",
"client_result": "SUCCESS"
},
"device": { "ipv4": "187.61.115.149", "session_id": "455e1d2f-6db1-409a-8089-80e40cf37a12" }
}8.3 CHANGE_PROFILE: operation × data
CHANGE_PROFILE combines an operation (type) with the data it operates on (field), and carries the previous value and the new one when that applies. Passwords do not go here: they have their own event, CHANGE_PASSWORD.
field ↓ / type → | ADD | UPDATE | DELETE | CONFIRM |
|---|---|---|---|---|
EMAIL | ✓ | ✓ | ✓ | ✓ |
PHONE | ✓ | ✓ | ✓ | ✓ |
ACCOUNT | ✓ | ✓ | ||
CARD | ✓ | ✓ | ||
PERSONAL_INFO | ✓ | |||
CATEGORY New | ✓ | |||
BENEFICIARY New | ✓ | ✓ | ✓ |
Phone or email change: Koin asks for a token to confirm the change.
{
"type": "CHANGE_PROFILE",
"details": {
"user_id": "company_8812345",
"type": "UPDATE",
"field": "PHONE",
"old_phone": { "area_code": "11", "number": "987654321", "type": "MOBILE" },
"new_phone": { "area_code": "21", "number": "912345678", "type": "MOBILE" },
"client_result": "SUCCESS"
},
"device": { "ipv4": "187.61.115.149", "session_id": "455e1d2f-6db1-409a-8089-80e40cf37a12" }
}
Special case: email without a password.If the user changes their email and did not have a password (for example, they signed in with a social network), they must create one. In that case you also send a
CHANGE_PASSWORD.
flowchart LR
A["User changes<br/>email or phone"]
B["CHANGE_PROFILE"]
T["Confirmation<br/>token"]
Q{"Did the email change and<br/>was there no password?"}
P["CHANGE_PASSWORD"]
F["End"]
A --> B
B --> T
T --> Q
Q -->|"Yes"| P
Q -->|"No"| F
classDef dark fill:#0E2419,color:#ffffff,stroke:#0E2419
class B,P dark
classDef decision fill:#E6F6EA,color:#0A7F25,stroke:#10B132
class T decision
Figure 3 · Contact change: 2 events, 1 conditional.
Category and beneficiary changes are in the Loyalty Pack.
8.4 CLOSE_ACCOUNT
A single event, always confirmed with a token, whether or not the user has a password.
{
"type": "CLOSE_ACCOUNT",
"details": { "user_id": "company_8812345" },
"device": { "ipv4": "187.61.115.149", "session_id": "455e1d2f-6db1-409a-8089-80e40cf37a12" }
}- The user asks to close their account.
- You send
CLOSE_ACCOUNT. - Koin responds
receivedwith the strategy (token). - The user validates the token, Koin sends you the final decision, and you execute the closure.
- You report the final result.
9. Reporting the final result
New · When the action ends, you report how it ended with a separate notification.
sequenceDiagram
participant Backend as Customer backend
participant Koin
Backend->>Koin: POST /v1/ato/evaluations
Koin-->>Backend: evaluation_id + decision
Note over Backend: The user completes or abandons the action
Backend->>Koin: PATCH /v1/ato/notifications/{evaluation_id}
Koin-->>Backend: 200 OK
Figure 4 · Final-result notification.
Three simple rules:
- The
evaluation_idis enough. You do not need to repeat the event type: the mechanism is the same for the whole catalog. - It is independent of the verification. A user can validate the token correctly and still not finish the change. That is why they are two different pieces of data.
- It closes the learning loop. For each action, Koin stores the context, the strategy that was applied, and the final result. With that, it calibrates its decisions for your program.
If your system orchestrates the strategy
When Koin responds received and you run the verification yourself (see §12), you tell Koin how it went with the same PATCH endpoint. Koin combines that result with the rest of the signals and sends you the final decision (approved or denied) through callback_url.
{
"type": "STATUS",
"notification_date": "2026-10-16T14:10:44.307",
"strategies": {
"type": "VerificationCode",
"results": [
{ "channel": "WhatsApp", "status": "Accept" }
]
}
}strategies.results[].status: Accept · Reject. The same mechanism is used for 3DS (CollectAuthRecovery), with provider and mode; the full example is in Strategies. Contract: Send Notifications.
10. Blocking and recovering accounts
New · An evaluation protects one action. Account Services protects the whole account. You can find an account, restrict it when you suspect or confirm a takeover, and give it back to its owner when they regain control.
Account Services is part of the same Account Protection API: other endpoints, the same key, the same user_id, and its own reference.
| Method | Path | Purpose |
|---|---|---|
POST | /v1/accounts/search | Search accounts. |
POST | /v1/accounts/USER_ID/blocks | Block an account. |
POST | /v1/accounts/blocks | Bulk block. |
POST | /v1/accounts/USER_ID/quarantines | Quarantine an account. |
POST | /v1/accounts/quarantines | Bulk quarantine. |
POST | /v1/accounts/USER_ID/recoveries | Start a recovery, or record one that already ended. |
PATCH | /v1/accounts/USER_ID/recoveries/{recovery_id} | Complete a recovery that was started. |
POST | /v1/accounts/recoveries | Bulk recovery. |
You decide when to restrict an account
A denied from Koin is one reason to restrict an account. It is not the only one, and you do not need it. You can block an account whenever you decide to, for reasons Koin cannot see:
| Where the reason comes from | Examples |
|---|---|
| Signals Koin gives you | Repeated denied decisions, a high score, a member who answers "it wasn't me" to a notification. |
| Signals only you have | A support ticket, a chargeback, fraud found in another channel, an alert from a partner, or your own policy. |
That is why every restriction carries your own reason, an optional comment, and an external_reference with the case id in your system. Koin also restricts accounts on its own when its engine detects a takeover, and anyone (you, the Koin team, or the engine) can undo what another did.
10.1 Risk state
Every account has a risk state, risk_state. It is not the verdict of an evaluation, and it is independent of the lifecycle state: an account can be blocked and closed at the same time.
risk_state | Meaning | Effect on its evaluations |
|---|---|---|
NORMAL | No restrictions. | The engine decides as usual. |
QUARANTINED | Suspicious account, restricted as a precaution. | More friction: verifications or review instead of a direct denied. |
BLOCKED | Account taken over, or banned by you. | All its sensitive events return denied. |
RECOVERING | The legitimate owner is regaining access, until expires_at. | The events of the recovery flow pass with strong verification. |
Each block, quarantine, and recovery is a record with its own id, date, origin, and reason. The sequence of those records is the history of the account.
10.2 Transitions
| From | Operation | To |
|---|---|---|
NORMAL · QUARANTINED · RECOVERING | Create a block | BLOCKED |
NORMAL | Create a quarantine | QUARANTINED |
BLOCKED · QUARANTINED | Create a recovery (completed: false, the default) | RECOVERING |
BLOCKED · QUARANTINED | Create a recovery with completed: true | NORMAL |
RECOVERING | Complete the recovery (PATCH with completed: true) | NORMAL |
RECOVERING | expires_at passes (done by the engine) | BLOCKED or QUARANTINED, whichever it came from |
stateDiagram-v2
[*] --> NORMAL
NORMAL --> QUARANTINED: create quarantine
NORMAL --> BLOCKED: create block
QUARANTINED --> BLOCKED: create block
RECOVERING --> BLOCKED: create block
BLOCKED --> RECOVERING: create recovery
QUARANTINED --> RECOVERING: create recovery
BLOCKED --> NORMAL: recovery with completed true
QUARANTINED --> NORMAL: recovery with completed true
RECOVERING --> NORMAL: complete recovery
RECOVERING --> BLOCKED: expires_at passes
RECOVERING --> QUARANTINED: expires_at passes
Figure 5 · Risk states and the operations that move an account between them.
Three rules to remember:
- The only way out of
BLOCKEDorQUARANTINEDis a recovery. A blocked account never goes back to quarantine. If a block was a mistake, recover the account withclient_flow: FALSE_POSITIVE. - Reaching
NORMALupdates the account. Koin updateslast_recovered_atandrecovery_count. - When
expires_atpasses, the account is restricted again. The recovery is recorded as abandoned, and the account gets a new restriction of the same type it had before (a new block or quarantine with its own id; the earlier one is not resumed). The new restriction copiesreasonandexternal_reference, has Koin's engine as its origin, and points to the abandonedrecovery_id. Abandoned recoveries show where your flow adds friction, and they reveal someone who starts a recovery many times and never finishes it.
10.3 Search accounts
POST /v1/accounts/search?page=1&limit=50
You search with at least one filter. If you send several, all of them must match.
| Filter | Description |
|---|---|
user_id | The member identifier. |
document | The member document. |
email | An email of the account. |
phone | A phone of the account. |
device | merchant_device (your own device identifier) or koin_device_id (only if Koin shares device ids with you). |
risk_state | A list of states. It works on its own, for example to list every blocked account. |
A body without filters returns 400. It is a POST so documents and emails do not travel in the URL.
Pagination goes in query parameters: page (from 1) and limit (50 by default, 100 at most). The response returns page, limit, and total, the number of accounts that match the filters.
{
"email": "[email protected]",
"risk_state": ["BLOCKED", "RECOVERING"]
}Response:
{
"accounts": [
{
"user_id": "company_8812345",
"risk_state": "BLOCKED",
"risk_state_updated_at": "2026-10-19T17:40:00Z",
"last_recovered_at": "2026-08-02T10:15:00Z",
"recovery_count": 1,
"first_name": "Maria",
"last_name": "Silva",
"birth_date": "1988-04-12",
"document": { "type": "CPF", "number": "12345678909", "nationality": "BR" },
"emails": ["[email protected]"],
"phones": [{ "area_code": "11", "number": "912345678", "type": "MOBILE" }]
}
],
"page": 1,
"limit": 50,
"total": 1
}The search returns a list because one email, document, phone, or device can belong to more than one account. For accounts in RECOVERING, the summary also carries recovery_id and expires_at. You only see accounts of your own program, with data you sent to Koin, and only active emails and phones.
10.4 Block and quarantine
POST /v1/accounts/USER_ID/blocks and POST /v1/accounts/USER_ID/quarantines receive the same fields:
| Field | Required | Description |
|---|---|---|
reason | Yes | ATO_SUSPECTED · ATO_CONFIRMED · NOT_ME · FRAUD_CONFIRMED · INTERNAL_POLICY · OTHER |
comment | No | Free text. |
external_reference | No | The id of the case in your system. |
INTERNAL_POLICY and OTHER are for reasons that belong to your program. ATO_SUSPECTED and ATO_CONFIRMED tell Koin that you suspect or confirm an account takeover; NOT_ME records that the member said it was not them; FRAUD_CONFIRMED records confirmed fraud.
Block an account (POST /v1/accounts/company_8812345/blocks):
{
"reason": "ATO_CONFIRMED",
"comment": "Email change from an unknown device",
"external_reference": "case-8841"
}Response (201):
{
"block_id": "7c1e9a52-3f0b-4d8e-9a61-2b5d0f4c8e13",
"user_id": "company_8812345",
"reason": "ATO_CONFIRMED",
"risk_state": "BLOCKED",
"previous_risk_state": "NORMAL",
"risk_state_updated_at": "2026-10-19T17:40:00Z"
}A quarantine works the same way on /quarantines, with quarantine_id and risk_state: QUARANTINED. An account that is BLOCKED or RECOVERING cannot be quarantined: the call returns 409.
10.5 Recovery
A recovery is a resource of the account. You create it with POST /v1/accounts/USER_ID/recoveries, and the completed field says whether it already finished. There are two cases:
| Case | When it happens | What you send |
|---|---|---|
| Self-service, in two steps | The user taps "Recover my account" in your app and goes through your own recovery flow. | POST with completed: false, then PATCH with completed: true. |
| Call center, in one step | An analyst of your team reviews the account, validates the identity, and removes what the attacker added while the account is still BLOCKED. You tell Koin once, at the end. | POST with completed: true. |
Self-service. With completed: false, the account moves to RECOVERING until expires_at. The events of your recovery flow (for example, the CHANGE_PASSWORD of the reset) pass with strong verification instead of being denied because of the block. When you are sure the user recovered the account, you complete it with the PATCH. If the user abandons and expires_at passes, the account is restricted again by itself.
sequenceDiagram
actor User
participant App as Your app
participant Koin
User->>App: Taps Recover my account
App->>Koin: POST /v1/accounts/{user_id}/recoveries with completed false
Koin-->>App: 201 risk_state RECOVERING
User->>App: Resets the password
App->>Koin: CHANGE_PASSWORD evaluation
Koin-->>App: received with strong verification instead of denied
App->>Koin: PATCH /v1/accounts/{user_id}/recoveries/{recovery_id} with completed true
Koin-->>App: 200 risk_state NORMAL
opt The user abandons and expires_at passes
Koin->>Koin: Records the recovery as abandoned and restricts the account again
end
Figure 6 · Self-service recovery, in two steps.
Call center. The account stays BLOCKED during the review, and you register the recovery once it is done.
sequenceDiagram
actor Analyst as Your analyst
participant System as Your system
participant Koin
Note over System,Koin: The account stays BLOCKED during the review
Analyst->>Analyst: Validates identity and removes what the attacker added
Analyst->>System: Confirms the recovery
System->>Koin: POST /v1/accounts/{user_id}/recoveries with completed true
Koin-->>System: 201 risk_state NORMAL
Figure 7 · Call-center recovery, in one step.
Create (POST):
| Field | Required | Description |
|---|---|---|
completed | No | false by default: the account moves to RECOVERING. true: it moves to NORMAL. |
client_flow | Yes | Your recovery flow (Koin does not own these flows): PASSWORD_RESET · ACCOUNT_REVIEW · IDENTITY_VERIFICATION · FALSE_POSITIVE · OTHER |
reported_by | Yes | Who intends to recover the account: USER or COMMERCE. |
expires_at | No | Only with completed: false. A future ISO-8601 date and time until which the account can stay RECOVERING. If you do not send it, Koin applies a default period. With completed: true it returns 400. |
sessions_revoked | No | You revoked the sessions and tokens of the account. |
comment | No | Free text. |
Complete (PATCH):
| Field | Required | Description |
|---|---|---|
completed | Yes | It only accepts true; false returns 400. |
sessions_revoked | No | You revoked the sessions and tokens of the account. |
comment | No | Free text. |
client_flow and reported_by are set when the recovery is created and do not change.
Start a self-service recovery (POST /v1/accounts/company_8812345/recoveries):
{
"client_flow": "PASSWORD_RESET",
"reported_by": "USER",
"expires_at": "2026-10-20T18:10:00Z"
}Response (201):
{
"recovery_id": "3d8f2b71-9c4e-4a05-b6d2-8e1f7a3c5b90",
"user_id": "company_8812345",
"completed": false,
"client_flow": "PASSWORD_RESET",
"reported_by": "USER",
"expires_at": "2026-10-20T18:10:00Z",
"risk_state": "RECOVERING",
"previous_risk_state": "BLOCKED",
"risk_state_updated_at": "2026-10-20T17:40:00Z"
}Finish it (PATCH /v1/accounts/company_8812345/recoveries/3d8f2b71-9c4e-4a05-b6d2-8e1f7a3c5b90):
{
"completed": true,
"sessions_revoked": true,
"comment": "User reset the password and reviewed the account"
}Record a call-center recovery in one step (POST /v1/accounts/company_8812345/recoveries):
{
"completed": true,
"client_flow": "ACCOUNT_REVIEW",
"reported_by": "COMMERCE",
"sessions_revoked": true,
"comment": "User validated their identity through the call center"
}
What Koin does not ask you forKoin does not ask in the request for what it can already see in your evaluations: the password change (a
CHANGE_PASSWORDwith a successful result), the MFA verifications, and the evaluations linked to the attack come from the events you already send. The rest (sessions_revoked,comment) Koin does not verify: it records what you report.
Particular cases:
POSTwithcompleted: falseon an account that is alreadyRECOVERING(the user started the recovery again) returns200with the same recovery in progress (the samerecovery_id) and renewsexpires_at, with the value you sent or the default period.POSTwithcompleted: trueon an account that isRECOVERINGreturns409: the recovery in progress is completed with thePATCH.POSTon aNORMALaccount returns409if the user never went through a recovery, because there is nothing to recover. If the user did (for example, you retry after a timeout), it returns200with that recovery.PATCHon a recovery that is already completed returns200, so you can retry after a timeout.PATCHon an expired recovery returns409withEXPIRED: an abandoned recovery stays abandoned. If your analyst did finish the recovery, you register it with aPOSTwithcompleted: true.PATCHon a recovery interrupted by a block returns409.PATCHwith arecovery_idthat does not exist for that account returns404.
10.6 Bulk operations
Every bulk endpoint is the same path without USER_ID. It receives a list accounts of up to 1,000 entries, each one with its own fields (the same as the individual creation, plus user_id). There is no bulk PATCH: completing a two-step recovery is done user by user.
{
"accounts": [
{ "user_id": "company_8812345", "reason": "ATO_CONFIRMED" },
{ "user_id": "company_7700001", "reason": "ATO_CONFIRMED" },
{ "user_id": "company_0000000", "reason": "ATO_CONFIRMED" }
]
}Response:
{
"summary": { "received": 3, "blocked": 2, "not_found": 1, "rejected": 0 },
"results": [
{ "user_id": "company_8812345", "result": "BLOCKED", "block_id": "7c1e9a52-3f0b-4d8e-9a61-2b5d0f4c8e13" },
{ "user_id": "company_7700001", "result": "BLOCKED", "block_id": "a40d6e38-1b7f-4c92-8d35-6f0e2c9b1a74" },
{ "user_id": "company_0000000", "result": "NOT_FOUND" }
]
}- It always responds
200with 1,000 entries or fewer, with one result per entry in the same order as the request. An invalid entry does not make the others fail. - More than 1,000 entries returns
400for the whole request. - If the same
user_idappears twice, the entries are processed in order; the second one changes nothing and gets the same result.
10.7 Results, retries, and errors
Every operation can be retried. Creating a resource returns 201. If the account was already in that state, it returns 200 with the existing resource (the same id), with no side effects and no new entry in the history. There are no ALREADY_* results.
A retry with different data (for example, another reason) does not change the account or the recorded reason either: Koin keeps the original, which is the one that caused the restriction. The only exception is the expires_at of a recovery in progress, which is renewed.
In individual responses, the resulting state is in risk_state. In bulk responses, each entry carries a result:
| Operation | Result (201 / 200) | Not allowed (409 individual · result in bulk) |
|---|---|---|
| Create a block | BLOCKED | — |
| Create a quarantine | QUARANTINED | REJECTED: the account is BLOCKED or RECOVERING |
Create a recovery with completed: false | RECOVERING | REJECTED: the account is NORMAL |
Create a recovery with completed: true | NORMAL | REJECTED: the account is RECOVERING (complete it with the PATCH) or NORMAL |
Complete a recovery (PATCH, individual only) | NORMAL | EXPIRED: the recovery expired |
In every operation, an account that Koin does not know for your program is NOT_FOUND (404 for an individual call). An invalid entry in a bulk call is REJECTED with an error field (400 for an individual call). The summary of a bulk call counts the entries by result.
11. Loyalty Pack: Account Protection for points programs
A loyalty account is a wallet. Points are redeemed for tickets, transferred, and sold on parallel markets. The attacker is not after the account: they are after the balance.
To date, no account-protection provider publishes a data model for points programs. Koin models it with pieces that add to the account events you already know:
| Piece | What it captures | Where it lives | Endpoint |
|---|---|---|---|
| Points profile | The member's balance and tier | SIGN_UP and transactions with points | Account events and transactions |
| Category (tier) | Tier changes, by the member or by the system | CHANGE_PROFILE · CATEGORY | Account events |
| Beneficiaries | Who can fly with the member's points | CHANGE_PROFILE · BENEFICIARY | Account events |
| Transactions with points | Points purchase and purchase with points (transfer: under construction) | POST /v1/antifraud/evaluations | Transactions |
Anatomy of an account takeover in loyalty
Each step of the attack is an event Koin sees, and they are all linked by the same user_id:
sequenceDiagram
actor Attacker
participant Program as Loyalty program
participant Koin
Attacker->>Program: Login with leaked credentials
Program->>Koin: LOGIN with a new device and an IP from another state
Attacker->>Program: Changes the account email
Program->>Koin: CHANGE_PROFILE EMAIL UPDATE
Koin-->>Program: received with VerificationCode to the original channel
Attacker->>Program: Adds an unknown beneficiary
Program->>Koin: CHANGE_PROFILE BENEFICIARY ADD
Attacker->>Program: Redeems the full balance for a ticket
Program->>Koin: Redemption with Points payments and points_balance
Koin-->>Program: denied because the redemption consumes 100 percent of the balance
Figure 8 · Anatomy of an account takeover in a loyalty program.
None of these actions is suspicious on its own. The sequence is, and Koin sees it in full because every endpoint shares the member's identity.
From there, your program can restrict the account or start a recovery (see §10). Koin's denied is one signal among others: you can block whenever your own reasons say so.
11.1 Points profile at signup
New · SIGN_UP requires the same fields as the API Reference: user_id, first_name, last_name, birth_date, email, and profile. Inside profile, category and points_balance are required.
| Field | Type | Required | Description |
|---|---|---|---|
user_id | string | Yes | Member identifier. |
first_name | string | Yes | First name. |
last_name | string | Yes | Last name. |
birth_date | string (YYYY-MM-DD) | Yes | Date of birth. |
email | string | Yes | Email contact. |
profile.points_balance | integer | Yes | Points balance at the time of the request. |
profile.category | string | Yes | The member's category or tier. |
{
"type": "SIGN_UP",
"details": {
"user_id": "company_8812345",
"first_name": "Maria",
"last_name": "Souza",
"birth_date": "1988-03-14",
"email": "[email protected]",
"document": { "type": "CPF", "number": "12345678909", "nationality": "BR" },
"authentication_type": "PASSWORD",
"profile": {
"points_balance": 0,
"category": "BASIC"
}
},
"device": { "ipv4": "187.61.115.149", "session_id": "455e1d2f-6db1-409a-8089-80e40cf37a12" }
}The balance is not updated with
CHANGE_PROFILE. Koin receives the current balance on every transaction with points (see §11.4), right where it is used.
11.2 Category (tier) change
New · A tier change is reported with CHANGE_PROFILE, operation UPDATE on the CATEGORY field.
A category change is normally triggered by the program's system, not by the user. That is why CHANGE_PROFILE includes initiated_by, so Koin can tell an automatic change from one made by the member.
initiated_by | Who started the change |
|---|---|
USER | The member, from the app or the site. |
SYSTEM | The customer's system (tier requalification, migration, support). |
{
"type": "CHANGE_PROFILE",
"details": {
"user_id": "company_8812345",
"type": "UPDATE",
"field": "CATEGORY",
"old_value": "SILVER",
"new_value": "GOLD",
"initiated_by": "SYSTEM"
}
}
Why it mattersA tier upgrade changes the member's limits (redemptions, beneficiaries, cabin upgrades). A tier change started by the user, or right before a large redemption, is a signal Koin takes into account.
11.3 Beneficiaries
New · Beneficiaries are the people who can fly with the member's points. They are the natural destination of an account takeover: the attacker adds a third party and redeems in their name.
They are reported with CHANGE_PROFILE on the BENEFICIARY field. Each event carries one beneficiary. To add or remove several people, send one evaluation per person. There are three operations:
| Operation | When | What you send |
|---|---|---|
ADD | A beneficiary is added. | beneficiary: the new beneficiary. |
DELETE | A beneficiary is removed. | beneficiary with only the document. |
UPDATE | A beneficiary's data is changed. | old_beneficiary with the document of the beneficiary that changes, and new_beneficiary with their new data. Same old/new format as a phone change. |
A full beneficiary (ADD, and new_beneficiary on UPDATE) always includes:
| Field | Type | Description |
|---|---|---|
first_name | string | First name. |
last_name | string | Last name. |
birth_date | string (YYYY-MM-DD) | Date of birth. |
gender | string | FEMALE · MALE · OTHER. |
document | object | type and number (and optionally nationality). Examples: DNI, CPF, CNPJ, CUIL, CI, PASSPORT. PASSPORT is used for foreign beneficiaries. |
email | string | The beneficiary's email. |
phone | object | area_code, number, and type, the same as in the rest of the API. |
address | object | Address, with the same schema as the rest of the API (street, number, city, state, zip_code, country_code…). |
On DELETE, beneficiary carries only the document. On UPDATE, old_beneficiary does the same: it is the key that identifies the beneficiary you are changing.
Add a beneficiary:
{
"type": "CHANGE_PROFILE",
"details": {
"user_id": "company_8812345",
"type": "ADD",
"field": "BENEFICIARY",
"initiated_by": "USER",
"beneficiary": {
"first_name": "João",
"last_name": "Souza",
"birth_date": "2012-07-02",
"gender": "MALE",
"document": { "type": "CPF", "number": "98765432100", "nationality": "BR" },
"email": "[email protected]",
"phone": { "area_code": "11", "number": "987650000", "type": "MOBILE" },
"address": { "street": "Rua Augusta", "number": "1500", "city": "São Paulo", "state": "SP", "zip_code": "01304-001", "country_code": "BR" }
}
},
"device": { "ipv4": "187.61.115.149", "session_id": "455e1d2f-6db1-409a-8089-80e40cf37a12" }
}Remove a beneficiary:
{
"type": "CHANGE_PROFILE",
"details": {
"user_id": "company_8812345",
"type": "DELETE",
"field": "BENEFICIARY",
"initiated_by": "USER",
"beneficiary": {
"document": { "type": "CPF", "number": "98765432100" }
}
},
"device": { "ipv4": "187.61.115.149", "session_id": "455e1d2f-6db1-409a-8089-80e40cf37a12" }
}Update a beneficiary:
{
"type": "CHANGE_PROFILE",
"details": {
"user_id": "company_8812345",
"type": "UPDATE",
"field": "BENEFICIARY",
"initiated_by": "USER",
"old_beneficiary": {
"document": { "type": "CPF", "number": "98765432100" }
},
"new_beneficiary": {
"first_name": "João",
"last_name": "Souza",
"birth_date": "2012-07-02",
"gender": "MALE",
"document": { "type": "CPF", "number": "98765432100", "nationality": "BR" },
"email": "[email protected]",
"phone": { "area_code": "21", "number": "912340000", "type": "MOBILE" },
"address": { "street": "Rua Augusta", "number": "1500", "city": "São Paulo", "state": "SP", "zip_code": "01304-001", "country_code": "BR" }
}
}
}
Why it mattersKoin builds the program's beneficiary network. The same beneficiary document showing up on many unrelated accounts is the fingerprint of a miles broker. And changing a beneficiary's email or phone is a way to divert redemption notifications.
11.4 Points transactions
Purchasing points and purchasing products with points are evaluated in the transactions endpoint of Account Protection. It has its own reference, and it is the same product: the same key, and the same member identity as the account events.
| Transaction | Status |
|---|---|
| Points purchase | Available |
| Purchase with points (mixed payment) | Available |
| Points transfer | Under construction: the model will be published in a future version of this guide. |
POST /v1/antifraud/evaluationsFull reference: API Reference · Transactional.
The golden rule:buyer.idis requiredand it must carry the same identifier you send as
user_idon account events. That is what lets Koin know which member is making the transaction and connect it with everything that happened on their account.
Points purchase
It is modeled with what already exists: a Generic item that indicates Points, with the quantity (quantity), the price (price), and the transaction total. The payment method is the usual one (card, Pix, and so on).
The item's seller_id indicates which loyalty program the points belong to (the airline, a bank, another coalition partner). That is how Koin distinguishes a purchase of the program's own points from a purchase of points from a partner program.
{
"type": "Ecommerce",
"buyer": {
"id": "company_8812345",
"first_name": "Maria",
"last_name": "Souza",
"email": "[email protected]",
"document": { "type": "CPF", "number": "12345678909", "nationality": "BR" }
},
"items": [
{
"type": "Generic",
"id": "PTS-10000",
"name": "Purchase of 10,000 points",
"category": { "id": "POINTS", "name": "Points" },
"seller_id": "COMPANY",
"quantity": 10000,
"price": { "currency_code": "BRL", "value": 0.07 }
}
],
"payments": [
{
"method": "CreditCard",
"amount": { "currency_code": "BRL", "value": 700.00 },
"installments": 1,
"details": { "bin": "424242", "last_digits": "4444", "brand_name": "Visa" },
"payer": { "first_name": "Maria", "last_name": "Souza", "email": "[email protected]" }
}
],
"transaction": {
"reference_id": "COMPANY-PTS-000123",
"country_code": "BR",
"total_amount": { "currency_code": "BRL", "value": 700.00 }
},
"device": { "ipv4": "187.61.115.149", "session_id": "455e1d2f-6db1-409a-8089-80e40cf37a12" }
}Purchase with points and mixed payment
Mixed payment methods are supported: in payments you send one method for each part of the payment, for example CreditCard for the cash portion and Points for the points used (quantity) with their equivalent amount.
In addition, the buyer includes their points profile in buyer.profile:
| Field | Required | Description |
|---|---|---|
buyer.profile.points_balance | Yes | Points balance before the transaction. |
buyer.profile.category | No | The member's category or tier. |
{
"type": "Ecommerce",
"buyer": {
"id": "company_8812345",
"first_name": "Maria",
"last_name": "Souza",
"email": "[email protected]",
"document": { "type": "CPF", "number": "12345678909", "nationality": "BR" },
"profile": {
"points_balance": 48000,
"category": "GOLD"
}
},
"items": [
{
"type": "Flight",
"id": "XY1234-2026-12-20",
"quantity": 1,
"price": { "currency_code": "BRL", "value": 1200.00 },
"itinerary_type": "ONEWAY",
"route_type": "DOMESTIC",
"routes": [
{
"position": 1,
"segments": [
{
"position": 1,
"airline_code": "XY",
"flight_number": 1234,
"departure": { "airport_code": "VCP", "date": "2026-12-20T08:15:00Z" },
"arrival": { "airport_code": "REC", "date": "2026-12-20T11:20:00Z" }
}
]
}
],
"passengers": [
{
"first_name": "João",
"last_name": "Souza",
"birth_date": "2012-07-02",
"document": { "type": "CPF", "number": "98765432100", "nationality": "BR" }
}
]
}
],
"payments": [
{
"method": "Points",
"quantity": 30000,
"amount": { "currency_code": "BRL", "value": 850.00 }
},
{
"method": "CreditCard",
"amount": { "currency_code": "BRL", "value": 350.00 },
"installments": 1,
"details": { "bin": "424242", "last_digits": "4444", "brand_name": "Visa" },
"payer": { "first_name": "Maria", "last_name": "Souza", "email": "[email protected]" }
}
],
"transaction": {
"reference_id": "COMPANY-RES-778812",
"country_code": "BR",
"total_amount": { "currency_code": "BRL", "value": 1200.00 }
},
"device": { "ipv4": "187.61.115.149", "session_id": "455e1d2f-6db1-409a-8089-80e40cf37a12" }
}
Why the prior balance mattersWith
points_balanceandquantity, Koin knows what share of the balance is being spent. Draining 100% of an account built up over years, from a new device, is the clearest signature of a taken-over account.
The Flight item (routes, segments, passengers with a document) is already part of the transactional model. With it, Koin cross-checks the redemption passengers against the account beneficiaries. See the item types in the API Reference · Transactional.
12. Operating modes
Each customer chooses how they operate, from the dashboard and without changing the API contract:
| Koin operates | The customer operates | |
|---|---|---|
| Strategies | The response includes a link to Koin's hosted page. The user verifies there and Koin sends you the final evaluation decision (approved or denied) through callback_url. | The response includes the recommended type. You run your validation, report its result through PATCH, and Koin sends you the final decision through callback_url. |
| Communications | Koin sends the token and the magic link on the customer's behalf, by email or WhatsApp. Koin does not send SMS. | You send the messages through your own channels. |
You can start with your current verifications and migrate to Koin's strategy by strategy.
13. Errors and best practices
Response codes
| Code | Meaning | What to do |
|---|---|---|
200 | Evaluation created, or an existing resource returned on a retry (Account Services). | Apply the decision. |
201 | Resource created (Account Services). | Store the id of the resource. |
400 | Validation error. Also a search without filters, or a bulk call with more than 1,000 entries. | Fix the request: causes indicates which field failed. |
401 | Unauthorized. | Check the key and the endpoint permissions. |
404 | Evaluation not found (PATCH), or an account that Koin does not know for your program (Account Services). | Check the evaluation_id or the user_id. |
409 | The state of the account does not allow the operation (for example, a quarantine on a blocked account), or the recovery expired (EXPIRED). | Read the current risk_state and choose the right operation. |
412 | The case is already closed (PATCH with type: STATUS). | Do not retry. The evaluation is no longer open. |
500 | Internal error. | Retry with backoff and apply your fallback policy. |
Validation error example:
{
"code": 400,
"message": "Validation errors",
"causes": ["type must not be blank"]
}Integration checklist
- The same
user_ideverywhere: account events, transactions (buyer.id), and Account Services. -
session_idon every event: integrate the device SDK on web and app. - Also send what fails: logins with
client_result: FAILEDand abandoned actions. - Close every cycle:
PATCHwith the final result of each evaluation. - Listen on
callback_url: that is where the final decision arrives when there was a strategy. - Define your fallback: what your flow does if Koin does not respond in time (for example, allow the login and ask for a token on high-value actions).
- Explicit timeouts: set a timeout on your HTTP client and record fallback cases.
- Do not reuse evaluations: one action, one evaluation.
- Block when you decide to: you do not need a
deniedfirst. Send your ownreasonand yourexternal_reference. - Recover, do not "unblock": the only way out of a block or a quarantine is a recovery. Use
client_flow: FALSE_POSITIVEif the block was a mistake. - Retry freely: every Account Services operation can be retried with the same data and returns the same resource.
14. From sandbox to production
flowchart LR
A["1. Sandbox<br/>integration and tests"]
B["2. Listener mode<br/>real data, without blocking"]
C["3. Customization<br/>optional, per program"]
D["4. Go-live<br/>active decisions"]
E["5. Continuous improvement<br/>final results and<br/>confirmed fraud"]
A --> B
B --> C
C --> D
D --> E
classDef go fill:#10B132,color:#06210F,stroke:#10B132
class D go
classDef decision fill:#E6F6EA,color:#0A7F25,stroke:#10B132
class E decision
Figure 9 · From sandbox to production.
- Sandbox. You integrate against
https://api-sandbox.koin.com.brwith your test key. - Listener mode. Koin receives real traffic and learns your program's behavior without affecting your members. On the transactions endpoint it is enabled with
transaction.listener_mode: true: the evaluation is recorded and always returns approved. - Customization (optional). Koin already decides on its own. If you want, you adjust rules, lists, thresholds, and strategies per event with our team.
- Go-live. You turn decisions on, event by event.
- Continuous improvement. Final results and confirmed fraud cases feed the engine back.
Let's talk?
The Koin team supports every integration, from sandbox to go-live. Full API Reference: api-docs.koin.com.br
Updated 20 days ago
