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

📘

New

New 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:

DecisionWhat it meansWhat you do
approvedNo risk signalsYou continue without friction
receivedA verification is requiredYou show the strategy Koin indicates (token, liveness, 3DS…)
deniedHigh risk or blocked accountYou 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.

FamilyEndpointsWhat it is for
Account eventsPOST /v1/ato/evaluations · PATCH /v1/ato/notifications/{evaluation_id}Evaluate each sensitive account action and report how it ended.
TransactionsPOST /v1/antifraud/evaluationsPoints purchases and purchases with points.
Account Services New/v1/accounts/…Search accounts, block them, quarantine them, and recover them.

2. Why Koin

KoinWhat is typical in the market
Decides on its own, and can be customizedKoin 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 verificationKoin 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 loyaltyPoints 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 NewSearch, 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 LatAmNative 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 endpointThe 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 documentationPublic, 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/json

Step 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 matters

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

ConceptIn one lineField
EventThe sensitive action you send to Koin so it can evaluate it.type
Verification strategyThe 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 contextThe 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 customerThe 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 resultHow the business action ended: it completed or it did not. It is reported later, separately.PATCH New
Risk stateThe 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_result happens 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: approved or denied.

6. Decisions and strategies

The three decisions

statusMeaningWhat your system does
approvedThe action may continue.Completes the action.
receivedIn 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.
deniedBlock.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), received always comes with a strategy the user resolves in the moment. It never leaves the user waiting for a manual review.

Available strategies

strategies[].typeWhat it validatesWhen it is typically used
VerificationCode6-digit token by email or WhatsApp, or magic link.Forgotten password, contact change, account closure.
LivenessFacial biometrics with a proof of life.High risk: contact change after a suspicious login.
CollectAuthRecovery3DS2 authentication (FRICTIONLESS or CHALLENGE).Actions that involve a card.
ManualVerificationReview 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.

EventWhen you send itWhen you do notReference verification
LOGINThe 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_UPAccount 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_PASSWORDAny 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_PROFILEA 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_ACCOUNTThe 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

FieldRequiredWhy it matters
typeYesDefines the event and the details schema.
details.user_idYesThe identity that links every account event, in Account Protection and in the transactional API. Always use the same identifier.
device.session_idRecommendedGenerated by the device SDK. Without it, Koin loses the strongest signal against account takeover.
device.ipv4 / device.ipv6Recommended (one of the two)Geolocation, ASN, and detection of distributed attacks.
device.merchant_deviceOptionalYour own device identifier, so you can cross-check it with Koin's.
callback_urlOptionalHTTPS endpoint where you receive the final evaluation decision when there was a strategy.
sla_dateOptionalHow 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.

authenticationUse caseReference verification
unauthenticatedI forgot my password. The user, without a session, starts the process. The previous password stays valid until it finishes.Yes (for example, a token).
authenticatedChange from the profile. The user, with an active session, changes their password.Typically no.
systemForced 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 →ADDUPDATEDELETECONFIRM
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" }
}
  1. The user asks to close their account.
  2. You send CLOSE_ACCOUNT.
  3. Koin responds received with the strategy (token).
  4. The user validates the token, Koin sends you the final decision, and you execute the closure.
  5. 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_id is 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.

MethodPathPurpose
POST/v1/accounts/searchSearch accounts.
POST/v1/accounts/USER_ID/blocksBlock an account.
POST/v1/accounts/blocksBulk block.
POST/v1/accounts/USER_ID/quarantinesQuarantine an account.
POST/v1/accounts/quarantinesBulk quarantine.
POST/v1/accounts/USER_ID/recoveriesStart 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/recoveriesBulk 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 fromExamples
Signals Koin gives youRepeated denied decisions, a high score, a member who answers "it wasn't me" to a notification.
Signals only you haveA 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_stateMeaningEffect on its evaluations
NORMALNo restrictions.The engine decides as usual.
QUARANTINEDSuspicious account, restricted as a precaution.More friction: verifications or review instead of a direct denied.
BLOCKEDAccount taken over, or banned by you.All its sensitive events return denied.
RECOVERINGThe 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

FromOperationTo
NORMAL · QUARANTINED · RECOVERINGCreate a blockBLOCKED
NORMALCreate a quarantineQUARANTINED
BLOCKED · QUARANTINEDCreate a recovery (completed: false, the default)RECOVERING
BLOCKED · QUARANTINEDCreate a recovery with completed: trueNORMAL
RECOVERINGComplete the recovery (PATCH with completed: true)NORMAL
RECOVERINGexpires_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 BLOCKED or QUARANTINED is a recovery. A blocked account never goes back to quarantine. If a block was a mistake, recover the account with client_flow: FALSE_POSITIVE.
  • Reaching NORMAL updates the account. Koin updates last_recovered_at and recovery_count.
  • When expires_at passes, 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 copies reason and external_reference, has Koin's engine as its origin, and points to the abandoned recovery_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.

FilterDescription
user_idThe member identifier.
documentThe member document.
emailAn email of the account.
phoneA phone of the account.
devicemerchant_device (your own device identifier) or koin_device_id (only if Koin shares device ids with you).
risk_stateA 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:

FieldRequiredDescription
reasonYesATO_SUSPECTED · ATO_CONFIRMED · NOT_ME · FRAUD_CONFIRMED · INTERNAL_POLICY · OTHER
commentNoFree text.
external_referenceNoThe 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:

CaseWhen it happensWhat you send
Self-service, in two stepsThe 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 stepAn 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):

FieldRequiredDescription
completedNofalse by default: the account moves to RECOVERING. true: it moves to NORMAL.
client_flowYesYour recovery flow (Koin does not own these flows): PASSWORD_RESET · ACCOUNT_REVIEW · IDENTITY_VERIFICATION · FALSE_POSITIVE · OTHER
reported_byYesWho intends to recover the account: USER or COMMERCE.
expires_atNoOnly 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_revokedNoYou revoked the sessions and tokens of the account.
commentNoFree text.

Complete (PATCH):

FieldRequiredDescription
completedYesIt only accepts true; false returns 400.
sessions_revokedNoYou revoked the sessions and tokens of the account.
commentNoFree 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 for

Koin does not ask in the request for what it can already see in your evaluations: the password change (a CHANGE_PASSWORD with 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:

  • POST with completed: false on an account that is already RECOVERING (the user started the recovery again) returns 200 with the same recovery in progress (the same recovery_id) and renews expires_at, with the value you sent or the default period.
  • POST with completed: true on an account that is RECOVERING returns 409: the recovery in progress is completed with the PATCH.
  • POST on a NORMAL account returns 409 if 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 returns 200 with that recovery.
  • PATCH on a recovery that is already completed returns 200, so you can retry after a timeout.
  • PATCH on an expired recovery returns 409 with EXPIRED: an abandoned recovery stays abandoned. If your analyst did finish the recovery, you register it with a POST with completed: true.
  • PATCH on a recovery interrupted by a block returns 409.
  • PATCH with a recovery_id that does not exist for that account returns 404.

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 200 with 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 400 for the whole request.
  • If the same user_id appears 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:

OperationResult (201 / 200)Not allowed (409 individual · result in bulk)
Create a blockBLOCKED—
Create a quarantineQUARANTINEDREJECTED: the account is BLOCKED or RECOVERING
Create a recovery with completed: falseRECOVERINGREJECTED: the account is NORMAL
Create a recovery with completed: trueNORMALREJECTED: the account is RECOVERING (complete it with the PATCH) or NORMAL
Complete a recovery (PATCH, individual only)NORMALEXPIRED: 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:

PieceWhat it capturesWhere it livesEndpoint
Points profileThe member's balance and tierSIGN_UP and transactions with pointsAccount events and transactions
Category (tier)Tier changes, by the member or by the systemCHANGE_PROFILE · CATEGORYAccount events
BeneficiariesWho can fly with the member's pointsCHANGE_PROFILE · BENEFICIARYAccount events
Transactions with pointsPoints purchase and purchase with points (transfer: under construction)POST /v1/antifraud/evaluationsTransactions

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.

FieldTypeRequiredDescription
user_idstringYesMember identifier.
first_namestringYesFirst name.
last_namestringYesLast name.
birth_datestring (YYYY-MM-DD)YesDate of birth.
emailstringYesEmail contact.
profile.points_balanceintegerYesPoints balance at the time of the request.
profile.categorystringYesThe 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_byWho started the change
USERThe member, from the app or the site.
SYSTEMThe 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 matters

A 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:

OperationWhenWhat you send
ADDA beneficiary is added.beneficiary: the new beneficiary.
DELETEA beneficiary is removed.beneficiary with only the document.
UPDATEA 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:

FieldTypeDescription
first_namestringFirst name.
last_namestringLast name.
birth_datestring (YYYY-MM-DD)Date of birth.
genderstringFEMALE · MALE · OTHER.
documentobjecttype and number (and optionally nationality). Examples: DNI, CPF, CNPJ, CUIL, CI, PASSPORT. PASSPORT is used for foreign beneficiaries.
emailstringThe beneficiary's email.
phoneobjectarea_code, number, and type, the same as in the rest of the API.
addressobjectAddress, 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 matters

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

TransactionStatus
Points purchaseAvailable
Purchase with points (mixed payment)Available
Points transferUnder construction: the model will be published in a future version of this guide.
POST /v1/antifraud/evaluations

Full reference: API Reference · Transactional.

📘

The golden rule: buyer.id is required

and it must carry the same identifier you send as user_id on 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:

FieldRequiredDescription
buyer.profile.points_balanceYesPoints balance before the transaction.
buyer.profile.categoryNoThe 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 matters

With points_balance and quantity, 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 operatesThe customer operates
StrategiesThe 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.
CommunicationsKoin 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

CodeMeaningWhat to do
200Evaluation created, or an existing resource returned on a retry (Account Services).Apply the decision.
201Resource created (Account Services).Store the id of the resource.
400Validation error. Also a search without filters, or a bulk call with more than 1,000 entries.Fix the request: causes indicates which field failed.
401Unauthorized.Check the key and the endpoint permissions.
404Evaluation not found (PATCH), or an account that Koin does not know for your program (Account Services).Check the evaluation_id or the user_id.
409The 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.
412The case is already closed (PATCH with type: STATUS).Do not retry. The evaluation is no longer open.
500Internal 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_id everywhere: account events, transactions (buyer.id), and Account Services.
  • session_id on every event: integrate the device SDK on web and app.
  • Also send what fails: logins with client_result: FAILED and abandoned actions.
  • Close every cycle: PATCH with 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 denied first. Send your own reason and your external_reference.
  • Recover, do not "unblock": the only way out of a block or a quarantine is a recovery. Use client_flow: FALSE_POSITIVE if 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.

  1. Sandbox. You integrate against https://api-sandbox.koin.com.br with your test key.
  2. 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.
  3. Customization (optional). Koin already decides on its own. If you want, you adjust rules, lists, thresholds, and strategies per event with our team.
  4. Go-live. You turn decisions on, event by event.
  5. 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