The decision object
One object, the same in your app and in your event stream. Your app branches on one field and shows one message; your fraud team, your analysts and your auditors read the rest. Nobody sees an action without the reasons behind it.
Names and fields are illustrative and agreed in the sandbox before any contract.
On this page
Annotated
The decision at Ananya’s payment: ₹96,000 to a payee added 3 minutes earlier, 41 minutes into a call, with her screen shared. The object below is what the SDK returned to the app; the same object reached the FRM as an event.
The decision
decision.json
{
"decision_id": "dec_7q2_118",
"event": "payment.initiate",
"session": "ses_8f2c91ab",
"action": "hold",
"reasons": [
{ "code": "ACTIVE_CALL", "module": "scam" },
{ "code": "SCREEN_SHARED", "module": "device" },
{ "code": "DICTATION_PAUSES", "module": "behaviour" },
{ "code": "PAYEE_MULE_LINKED", "module": "graph" }
],
"message": {
"key": "payment_paused_callback",
"text": "Payment paused. Your money hasn’t left your account. We’ll call you on +91 •••• 3210."
},
"queue": "scam-callback",
"policy": { "id": "retail", "version": 14 },
"created_at": "2026-10-22T12:53:34Z"
}- decision_id
- Match the app, the FRM event and the log on this.
- event
- The payment, called before the instruction went to the rail.
- session
- Read since the app opened: the 41-minute call began before login.
- action
- Hold. The app shows the message and sends nothing.
- reasons
- Four codes from four modules: scam, device, behaviour, graph. Risk first.
- message
- What Ananya reads. Logged as shown.
- queue
- Your call-back queue, by the name your FRM already uses. The call goes to +91 •••• 3210, her registered number.
- policy
- Retail, version 14: the version that held a new payee to ₹10,000 for 12 hours.
Fields
Who reads each field: the app, which needs the action and the message; the FRM, which needs the reasons and the queue; and audit, which needs the policy version and the message as it was shown.
| Field | Type | What it carries | Read by |
|---|---|---|---|
| decision_id | string | The decision’s id. The same id reaches your app, the event your FRM receives and the decision log, so the three can be matched. | App, FRM, Audit |
| event | string | The moment your app called at: signup.start, login.attempt, device.change, payee.add or payment.initiate. | FRM, Audit |
| session | string | The app session the SDK has read since the app opened. Several decisions can share one session, and the call at a payment is read against everything earlier in it. | FRM, Audit |
| action | enum | What to do now: allow, warn, step_up, cool_off, hold or block. Your app branches on this one field and handles all six. | App, FRM, Audit |
| reasons | object[] | Why, as reason codes, risk first and then what held. The same codes reach your FRM and the log, so nobody sees an action without the signals behind it. | FRM, Audit |
| reasons[].code | string | One code from the catalogue, in capitals: ACTIVE_CALL, PAYEE_MULE_LINKED. | FRM, Audit |
| reasons[].module | string | The module that read it: scam, device, behaviour, graph, access, binding or one of the verification modules. | FRM, Audit |
| message | object | The words for the customer. Show the text as it is, or map the key to your own copy and languages. Whatever is shown is logged with the decision. | App, Audit |
| message.key | string | A stable key for the message, such as payment_paused_callback, so you can translate it or replace it with your own wording. | App |
| message.text | string | Plain words written for the customer, for this action at this moment. Never written for an analyst. | App, Audit |
| queue | string | null | The queue in your FRM or case manager a person should work, such as scam-callback, or null when nobody needs to act. The names are yours, set in the policy. | FRM |
| policy | object | The policy and version that made the decision, so any decision can be traced to the rule that applied at the time. | Audit |
| policy.id | string | The policy’s name, per journey and market: retail here. | Audit |
| policy.version | number | The version that applied. Every version is kept. | Audit |
| created_at | timestamp | When the decision was made, in UTC. | FRM, Audit |
The six actions
From the least to the most restrictive. Each comes with a message for the customer and a record for the FRM; most customers only ever see the first.
| Action | When | The customer sees | Your FRM receives |
|---|---|---|---|
| allow | Everything holds. | Payment sent. | ALLOW · known device · bound SIM · usual payee |
| warn | Something is unusual, but the customer is in control. | This payee is new to you. Check the name before you send. | WARN · first-time payee |
| step_up | Identity is uncertain. | Confirm it’s you with a quick face check. | STEP_UP · new device · face check requested |
| cool_off | A new device, SIM or payee. | You can send up to the starter limit now. You get the full limit in 12 hours. | COOL_OFF · new device 4 min · limit applied |
| hold | Signs of coercion or a mule payee. | Payment paused. Your money hasn’t left your account. We’ll call you on +91 •••• 3210. | HOLD · active call · screen share · mule-linked payee |
| block | Clear attack. | We couldn’t verify this session. Your money is safe. | BLOCK · virtual camera · emulator |
Branching on the action
Handle all six actions explicitly, so a new policy rule never falls through to a default you did not intend. The SDK has read the session since the app opened; the call passes only your own identifiers and the payment’s details.
PaymentViewModel.kt
// The SDK has read the session since launch.
val decision = DeepID.decide(
Event.PaymentInitiate(
customer = user.id,
payee = payee.vpa,
amount = Money(96_000, "INR"),
rail = "UPI",
)
)
when (decision.action) {
Action.ALLOW -> sendPayment()
Action.WARN -> showWarning(decision.message)
Action.STEP_UP -> stepUp(decision.message)
Action.COOL_OFF -> applyLimit(decision.message)
Action.HOLD -> showPaused(decision.message)
Action.BLOCK -> endSession(decision.message)
}PaymentModel.swift
// The SDK has read the session since launch.
let decision = try await DeepID.decide(
.paymentInitiate(
customer: user.id,
payee: payee.vpa,
amount: Money(96_000, "INR"),
rail: "UPI"
)
)
switch decision.action {
case .allow: sendPayment()
case .warn: showWarning(decision.message)
case .stepUp: stepUp(decision.message)
case .coolOff: applyLimit(decision.message)
case .hold: showPaused(decision.message)
case .block: endSession(decision.message)
}payment.ts
// The SDK has read the session since launch.
const decision = await deepid.decide(
"payment.initiate",
{
customer: user.id,
payee: payee.vpa,
amount: { value: 96000, currency: "INR" },
rail: "UPI",
},
)
switch (decision.action) {
case "allow": return sendPayment()
case "warn": return showWarning(decision.message)
case "step_up": return stepUp(decision.message)
case "cool_off": return applyLimit(decision.message)
case "hold": return showPaused(decision.message)
case "block": return endSession(decision.message)
}Customer messages
message.text is written for the customer, for this action at this moment: what happened to the money and what happens next, never an accusation. Show it as it is, or map message.key to your own copy and languages. The message shown is logged with the decision, so a complaint or an audit can see exactly what the customer read.
Which message a decision carries is set in the policy, rule by rule. The reasons it carries come from the catalogue, and the same object reaches your FRM through webhooks.
Wire one moment in the sandbox, before any contract.
Sign-up or login end to end, with the decision back in the app and as an event, so your engineers see the shape of it before anyone signs anything.