# Shukria Transaction Monitoring — API integration guide

**Audience:** the transaction backend team calling the risk engine.

For what the rules actually check and why, see the
[rule mapping](./mercury-shukria-rule-mapping.md). This document is the wire
contract only.

---

## 1. What this call does

You post one settled transaction. The engine scores it against the Shukria rule
set and tells you whether the merchant funding leg should be **held** for
scrutiny, **alerted** on, or left alone. It also opens and maintains the
investigation case behind the scenes — you do not need to do anything with
cases.

This is a **post-authorisation** call. Around a dozen rules key off
`ResponseCode`, so call it once the acquirer response is known, not before. It
is not in the authorisation path and must never gate an auth response.

---

## 2. Endpoint

```
POST /admin/api/models/7bba6ee7-e501-45a1-bddc-aef0eee17b51/verify
Content-Type: application/json
Authorization: Bearer <jwt>
```

The GUID identifies the Shukria model and is **pinned in the seed**, so it is
the same in every environment and survives a re-seed or a database restore.
Hard-code it. (The numeric `model_id` in responses is *not* stable — it is a
database auto-increment and will differ per environment. Never key on it.)

HTTP is the only transport. There is no queue-based entry point today.

### Getting a token

```
POST /admin/api/auth/login
Content-Type: application/json

{ "email": "<service-account>", "password": "<password>" }
```

```json
{ "success": true, "token": "eyJhbGci…", "role": "…", "tenant_id": 1 }
```

Tokens expire after **1 hour**. Cache the token and refresh on expiry or on a
401 — do not log in per transaction.

Use a dedicated service account, not a human operator's login: the account is
recorded against everything the call touches.

---

## 3. Request

The transaction goes inside a `Payload` object. Anything outside `Payload` is
ignored by the rules.

```json
{
  "Payload": {
    "TxnId": "TXN-20260822-0001",
    "MerchantId": "MID-100234",
    "AcceptorId": "TERM-100234-01",
    "MerchantName": "Al Noor Trading LLC",
    "MerchantActivationDate": "2026-05-14",
    "MerchantCategory": "CAT-B",
    "CardPan": "4111111111111111",
    "CardBin": "411111",
    "CurrencyAmount": 12000.00,
    "CurrencyCode": "AED",
    "TransactionType": "SALE",
    "ResponseCode": "00",
    "EntryMode": "CHIP",
    "MCC": "5812",
    "IssuerCountry": "AE",
    "BillingCountry": "AE",
    "TransactionCountry": "AE",
    "MerchantCountry": "AE",
    "TransactionCity": "Dubai",
    "TransactionDateTime": "2026-08-22T14:30:00.000Z",
    "LocalHour": 14,
    "CardExpiryDate": "2029-04",
    "Is3DSUsed": false
  }
}
```

### Fields

`R` = required, every rule set depends on it. `C` = conditionally required —
omitting it silently disables the rules listed. `O` = optional, carried for case
review only.

| Field | | Type | Notes |
|---|---|---|---|
| `TxnId` | R | string | Must be unique per transaction. Used as the case key, so a reused value merges two transactions into one case |
| `MerchantId` | R | string | Stable merchant identifier. Key for all merchant-level velocity |
| `AcceptorId` | R | string | Stable terminal identifier. Key for all terminal-level velocity |
| `CardPan` | R | string | See [§4](#4-cardpan-and-currencyamount-need-care) |
| `CurrencyAmount` | R | number | **AED.** See [§4](#4-cardpan-and-currencyamount-need-care) |
| `TransactionType` | R | string | `SALE` / `REFUND` / `REVERSAL` / `CHARGEBACK` |
| `ResponseCode` | R | string | Acquirer response. `"00"` = approved. Anything else counts as a decline |
| `IssuerCountry` | R | string | ISO-3166 alpha-2. Decides local vs international — `AE` is local |
| `LocalHour` | C | integer | 0–23, merchant local time. Omit → **SR03** (abnormal-hour) never fires. The engine does no timezone maths on `TransactionDateTime` |
| `MerchantActivationDate` | C | string | ISO-8601 date (`2026-05-14`) or datetime. Omit → **SR08, SR31, SR41, SR44** never fire |
| `MerchantCategory` | C | string | `CAT-A` / `CAT-B` / … Omit → **SR33a fires** and SR33b does not; an unknown category is treated as non-CAT-A and held at AED 100K |
| `MCC` | C | string | 4-digit. Omit → SR03's exemption list and SR07's watched-MCC list can't apply |
| `EntryMode` | C | string | Omit → **SR29** (fallback) and **SR43** (manual key) never fire |
| `TransactionCountry` | C | string | ISO-3166 alpha-2. Omit → **SR42** never fires |
| `CurrencyCode` | O | string | Informational — thresholds assume AED |
| `MerchantName` | O | string | Case review |
| `MerchantCountry` | O | string | Case review |
| `BillingCountry` | O | string | Case review |
| `TransactionCity` | O | string | Case review |
| `TransactionDateTime` | O | string | ISO-8601. Model reference date; case review |
| `CardBin` | O | string | Informational. BIN rules derive the BIN from `CardPan`, not this field |
| `CardExpiryDate` | O | string | Case review |
| `Is3DSUsed` | O | boolean | Case review |

Unknown extra fields are accepted and ignored. Send them if they help case
review.

---

## 4. `CardPan` and `CurrencyAmount` need care

These two are used to build velocity **grouping keys**, so their formatting
directly affects whether rules fire. Two traps:

**`CardPan` must be stable per card.** Card-level rules (SR12, SR14, SR16,
SR39) group on this value; terminal×card rules (SR04, SR45) group on
`CardPan:AcceptorId`; and SR05, SR15 and SR18 group on
`CardPan:AcceptorId:CurrencyAmount`. A per-transaction token changes every call
and every one of those rules goes permanently silent. Masking or tokenising is
fine as long as the same card always produces the same string.

**The first 6 characters must be the real BIN.** SR37 and SR38 group on
`<first-6-of-CardPan>:AcceptorId`. A mask that hides the leading digits
(`******1111`) collapses every card into one bucket and those rules will fire
constantly. `411111******1111` is fine.

**`CurrencyAmount` must have a consistent numeric representation.** SR05, SR15
and SR18 group on `AcceptorId:CurrencyAmount` and
`CardPan:AcceptorId:CurrencyAmount`, and the key is built from the value as
sent. `2000`, `2000.0` and `"2000.00"` produce three different buckets, so
same-amount detection breaks. Pick one form — a JSON number with 2 decimal
places — and use it everywhere.

---

## 5. Which transactions to send

| Transaction | Send? | Why |
|---|---|---|
| Approved sale | Yes | Baseline for every rate and velocity rule |
| Declined sale | **Yes** | SR11, SR14, SR16, SR25, SR29, SR30, SR31, SR45 exist entirely to watch declines |
| Refund / reversal | Yes | SR21, SR46, SR47 |
| Chargeback | Yes, if available on this path | SR10, SR22. If chargebacks reach you on a separate file feed instead, tell the risk team — there is an acquirer-lookup alternative already configured |

Send declines. It is the most common integration mistake, and roughly a quarter
of the rule set is blind without them.

---

## 6. Response

`200 OK`, `application/json`.

```json
{
  "decision": "review",
  "score": 0.7,
  "Activation": {
    "SR09 - High Value Transaction": { "Visible": true },
    "SR01 - Suspicious International Transaction": { "Visible": true }
  },
  "responseElevation": {
    "value": 9.0,
    "content": null, "redirect": null,
    "foreColor": null, "backColor": null,
    "createdDate": "2026-08-22T14:30:01.123Z"
  },
  "CreateCase": {
    "CaseId": 51,
    "CaseRef": "CASE-2026-965AAC",
    "CaseUrl": "http://demo.ctrmv.com:4010/admin/fraud/cases/51",
    "CaseWorkflowGuid": "Shukria Transaction Monitoring",
    "CaseWorkflowStatusGuid": "Hold",
    "CaseKey": "@Payload.TxnId",
    "dry_run": false
  },
  "model_id": 258,
  "model_guid": "7bba6ee7-e501-45a1-bddc-aef0eee17b51",
  "model_name": "Mercury Shukria Transaction Monitoring",
  "EntityAnalysisModelInstanceEntryGuid": "0f0f…",
  "EntityInstanceEntryId": "MID-100234",
  "ReferenceDate": "2026-08-22T14:30:00.000Z",
  "CreatedDate": "2026-08-22T14:30:01.100Z",
  "Sanction": { "hit": false, "matched_name": null, "list_type": null, "distance": 0.0 },
  "Payload": { "Payload": { "…": "echo of your request body" } },
  "TtlCounter": { "…": "counter values that were read" },
  "Abstraction": {}, "AbstractionCalculation": {},
  "InlineFunction": {}, "InlineScript": {}, "httpAdaptation": {}
}
```

### The three fields you act on

| Field | Meaning |
|---|---|
| `decision` | `"approve"` or `"review"`. This rule set never returns `"decline"` |
| `Activation` | Object keyed by the name of every rule that fired. Empty `{}` when none did |
| `responseElevation.value` | Severity 0–10 of the most severe firing rule. `null` when nothing fired |
| `CreateCase` | The case that was opened, or `null` if none was. See below |

### Linking to the case

When a rule opens a case, `CreateCase` carries everything needed to link an
operator straight to it:

| Field | Example | Use |
|---|---|---|
| `CaseRef` | `CASE-2026-965AAC` | Human-readable reference — show this in your UI, quote it in tickets and emails |
| `CaseUrl` | `http://demo.ctrmv.com:4010/admin/fraud/cases/51` | Deep link to the case view. Render it as the "open case" link |
| `CaseId` | `51` | Numeric id. Stable within an environment; prefer `CaseRef` for display |

Treat `CaseUrl` as opaque — build the link from this field rather than
assembling it from `CaseId`, so the path can change without a client release.
It is `null` if the deployment has no case base URL configured; fall back to
showing `CaseRef` alone.

Cases are **idempotent on `TxnId`**: re-posting the same transaction returns the
same `CaseId`/`CaseRef` rather than opening a second case. If several rules fire
on one transaction, `CreateCase` reports the first case opened.

### Deciding what to do

| `decision` | `Activation` | Action |
|---|---|---|
| `approve` | `{}` | Settle normally |
| `approve` | non-empty | **Alert.** Settle normally. A case is open for the risk team; no action from you |
| `review` | non-empty | **Hold.** Withhold the merchant funding leg for this transaction and await the risk team's release or confirmation |

`decision: "review"` is the hold signal. Do not parse rule names to decide —
new rules get added and thresholds get retuned without a backend release.

`score` is `0.05` when nothing fires, `0.70` when a hold rule fires. Use it for
reporting, not for branching.

### The rest

Everything else is diagnostic and safe to log and ignore. Two quirks worth
knowing so they don't surprise you:

* `Payload` is a **double-nested** echo — your body is returned verbatim, and
  your body already contains a `Payload` key. So the transaction is at
  `response.Payload.Payload.TxnId`.
* `CreateCase.CaseKey` returns the case-key *template* (`@Payload.TxnId`), not
  the resolved value. The resolved value is your `TxnId`.

---

## 7. Non-negotiable: call exactly once per transaction

**Every call mutates state.** It increments velocity counters, writes the
transaction journal, archives the transaction and dispatches side effects for
real. There is no dry-run mode on this endpoint.

A blind retry after a timeout **double-counts velocity** and can push a merchant
over a rate threshold that it never actually crossed. Case creation is
idempotent on `TxnId` within the workflow, so you will not get duplicate cases —
but the counters are not, and they drive most of the rule set.

* Treat a timeout as *unknown*, not *failed*. Do not retry automatically.
* If you must reconcile, queue the transaction and let the risk team replay it
  rather than re-posting.
* If you need at-least-once delivery semantics with safe retries, ask the risk
  team — that needs an idempotency key on the endpoint, which does not exist
  today.

---

## 8. Failure handling

The call is synchronous and does Redis and MySQL writes; once the acquirer
chargeback lookup is enabled it may also make an outbound HTTP call with a 2s
cap.

* Client timeout: **5 seconds**.
* **Fail open.** This is post-authorisation. If the risk engine is unreachable
  or slow, settle normally, log the transaction as unscored, and alert
  operations. Never block or delay settlement on it.
* Queue unscored transactions for replay if you can — a gap in the counters
  weakens the rate rules for the following 30 to 90 days.

### Error responses

| Status | Body | Meaning | Action |
|---|---|---|---|
| `401` | `{"success": false, "error": "Unauthorized", "reason": "…"}` | Missing, invalid or expired token | Re-authenticate once, retry once. This call has no side effects, so retrying is safe |
| `404` | `{"error": "model_not_found", "model_guid": "…"}` | Wrong GUID or wrong environment | Configuration error — do not retry |
| `5xx` | — | Engine fault | Fail open per above. Do not retry |

A 401 is the one safe retry: the request never reached the scoring pipeline.

---

## 9. Smoke tests

Run these against UAT after wiring up. Each should produce the stated
`decision`. Use a fresh `MerchantId`/`AcceptorId`/`CardPan` per scenario so
leftover counters don't interfere.

| # | Setup | Expect |
|---|---|---|
| 1 | The example body in §3 as-is (AED 12,000, local card) | `decision: "review"`, `Activation` contains `SR09` |
| 2 | Same, but `CurrencyAmount: 250.00` | `decision: "approve"`, `Activation: {}` |
| 3 | Same as 1, with `IssuerCountry: "GB"` | `review`, `SR09` **and** `SR01` |
| 4 | Three transactions in under 2 minutes, same `AcceptorId`, **three different `CardPan`s**, `CurrencyAmount: 600.00` | 1st `approve`; 2nd and 3rd `review` with `SR06` |
| 5 | Same as 2, but `LocalHour: 3` and `MCC: "5732"` | `review`, `SR03` |
| 6 | Same as 2, but `TransactionType: "REFUND"` and `CurrencyAmount: 6000.00` | `approve` with `SR47` in `Activation` — alert, not hold |
| 7 | Omit the `Authorization` header | `401` |

Test 6 is the one that proves hold-vs-alert is wired correctly: it fires a rule
but must **not** hold.

Test 4 deliberately uses three different cards. Reusing one card would also trip
SR05 (same card *and* same amount at the same terminal), which is correct
behaviour but makes the test ambiguous.

Note on test 2 and the empty `Activation`: this holds only while SR08 is
inactive, which is how it ships. See §11.

---

## 10. Cold start — the first 30 to 90 days

Most rules compare against a rolling window (30 or 90 days) that the engine
builds from the transactions you send it. Until those windows fill, the
behavioural rules are quiet. This is by design: every rate rule carries a
minimum-volume gate, so a merchant with two transactions cannot trip a "20%
decline rate".

Practically:

* **Day 1 onward** — all the single-transaction rules work immediately: amount
  thresholds, international card, abnormal hour, manual entry, fallback,
  high-value refund, AML limits. That is roughly half the rule set.
* **After a few hours** — short-window velocity (SR02, SR04, SR05, SR06, SR30,
  SR37, SR38, SR46) becomes meaningful.
* **After 30 days** — decline, refund and chargeback rate rules (SR11, SR21,
  SR22, SR23, SR25) start to have a denominator.
* **After 90 days** — average-ticket and round-figure-share rules (SR07, SR13,
  SR17, SR20, SR24, SR28) become meaningful.

**SR08 (Dormant Merchant) ships disabled** and must stay that way until one of
those windows is real. It fires on the *absence* of 30 days of activity, and at
go-live an empty counter is indistinguishable from a dormant merchant — enabling
it on day one would hold the first transaction of every established merchant.
The risk team enables it after 30 days of live traffic, or after replaying 30
days of history. Nothing is needed from the backend team beyond being aware that
hold volumes will step up when it is switched on.

If historical transactions can be replayed through this endpoint before go-live,
the windows fill immediately and all of the above collapses. Talk to the risk
team about a backfill — but note §7: replaying the same transaction twice
double-counts.

---

## 11. Before go-live

- [ ] Service account created; token caching and refresh-on-401 implemented
- [ ] Model GUID configured per environment
- [ ] `CardPan` verified stable per card, with real leading 6 digits (§4)
- [ ] `CurrencyAmount` verified consistent to 2 decimal places (§4)
- [ ] Declines, refunds and reversals all posted (§5)
- [ ] `MerchantActivationDate` and `MerchantCategory` sourced and populated
- [ ] `LocalHour` populated in merchant local time
- [ ] Retry behaviour reviewed against §7 — no automatic retry on timeout
- [ ] Fail-open path tested with the risk engine stopped
- [ ] Smoke tests in §9 pass on UAT
