How the Banks API talks to Checkmarble (Marble) today, and where it does not send Marble the full picture.
Reviewed:
D:\Workspace\API\Banks, branchfeature/marble-2026-09-18(commit010c0b49), with theArthaMarble0.1.1 NuGet package (AmlCompliance.Marble.dll) decompiled to confirm exactly what goes over the wire. File and line references below are to that commit.
| Today | |
|---|---|
| Money-movement flows that call Marble | 2 of 7 — Quick Transfer withdrawals and Noah deposits |
| Transaction fields sent | 16 of 42 that the Marble model supports — and 6 of those carry a wrong or incomplete value |
| Customer or wallet records sent | None — MarbleCustomer and MarbleWallet exist in the package but are never used |
| Decisions ignored | 1 critical — a Marble Decline on a settled Noah deposit still credits the wallet |
| Failed sends that are retried | None — ingest errors are logged and dropped |
flowchart LR
subgraph API["Banks API"]
QT["Quick Transfer<br/>withdrawal"]
ND["Noah fiat deposit<br/>webhook"]
NS["Noah settlement<br/>webhook"]
ED["External deposit<br/>approval"]
AD["Admin test endpoint<br/>POST api/marble/decide-all"]
SVC["MarbleComplianceService<br/>tenant switch · worst outcome · fail-safe"]
end
PKG["ArthaMarble 0.1.1<br/>MarbleAmlProvider"]
M[("Checkmarble<br/>Marble")]
QT -->|decide + ingest| SVC
ND -->|decide + ingest| SVC
NS -->|decide + ingest| SVC
ED -->|ingest only| SVC
AD -->|decide| SVC
SVC --> PKG
PKG -->|"POST /v1/decisions/all"| M
PKG -->|"POST /v1/ingest/transactions"| M
subgraph NC["Never reach Marble"]
X1["TazaPay · Openpayd · Mesta<br/>FiatRepublic · Artha deposits"]
X2["FiatRepublic withdrawal<br/>crypto withdrawal · refunds"]
X3["Customers · wallets<br/>admin approve / reject"]
end
| Part | Where | What it does |
|---|---|---|
| ArthaMarble package | NuGet ArthaMarble 0.1.1 |
HTTP client for Marble. DecideAllAsync posts to /{version}/decisions/all; IngestAsync posts to /{version}/ingest/{objectType}. Authenticates with X-API-KEY. Ships MarbleTransaction, MarbleCustomer and MarbleWallet models. Serializes with nulls omitted — any field the app leaves empty is silently left out of the request |
| MarbleComplianceService | ApplicationService/Aml/MarbleComplianceService.cs |
The only door to Marble. Checks the tenant has switched Marble on, runs every scenario for the object type, reduces the results to one gate, and never lets a Marble error crash a money flow |
| Tenant switch | Tenant setting ComplianceCheck |
Marble runs only when set to {"Provider":"Marble","IsEnable":1}. Anything else — including a missing or malformed value — means Marble is not called at all |
| Configuration | Program.cs lines 133–160 |
Base URL and API key come from the host's CoreConfig (provider marble), falling back to the Marble section of appsettings.json (https://api.checkmarble.com, v1, 30-second timeout). No secrets are stored in appsettings |
| Admin test endpoint | Controllers/WebhookController.cs line 461 |
POST api/marble/decide-all runs a decision on any posted JSON. Requires login plus an admin permission |
EvaluateAsync sends the transaction to every Marble scenario for transactions, then takes the worst outcome. The score kept is the highest single-scenario score.
| Marble returns | Gate result | What the money flow does |
|---|---|---|
| Decline or Block and Review | Block |
Hold — do not pay out or credit |
| Review | RequireApproval |
Hold for admin approval |
| Approve | proceed | Continue as normal |
| No scenario answered, or an unknown outcome | RequireApproval |
Hold for admin approval |
| Marble error or timeout | RequireApproval (fail-safe) |
Hold for admin approval — an outage never lets money through unreviewed |
| Marble switched off for the tenant | NotEvaluated |
Continue as if Marble did not exist; nothing is recorded |
The outcome and score are written to the transaction row as ComplianceStatus and ComplianceScore.
ApplicationService/WithdrawApplicationService.QuickTransfer.cs — decision at lines 226–236, ingest at 256–260.
sequenceDiagram
participant C as Customer
participant API as Banks API
participant FX as Rate feed
participant M as Marble
participant DB as Database
participant P as Payout provider
C->>API: Quick Transfer request
API->>API: Validate, build withdrawal
API->>FX: Convert amount to CAD
API->>M: Decide (all "transactions" scenarios)
M-->>API: Worst outcome + score
alt Approve
API->>DB: Save withdrawal
API->>P: Dispatch payout (unless admin approval or Travel Rule applies)
else Review, Block or Marble error
API->>DB: Save as Submitted, AML status Pending — held
end
API->>M: Ingest the withdrawal (best effort)
ApplicationService/WebhookApplicationService.NoahCallbacks.cs — decision at lines 196–201, ingest at 233–234.
sequenceDiagram
participant N as Noah
participant API as Banks API
participant M as Marble
participant DB as Database
N->>API: FiatDeposit webhook (Pending or Settled)
API->>API: Build deposit row, convert amount to CAD
API->>M: Decide (all "transactions" scenarios)
M-->>API: Worst outcome + score
alt Approve, no admin approval needed
API->>DB: Approved — wallet credited (fiat)
else Review or Marble error
API->>DB: Submitted for admin approval — not credited
else Decline or Block and Review
API->>DB: Pending — not credited
end
API->>M: Ingest the deposit (best effort)
Same file — decision at lines 633–637, promotion at 688–784, ingest at 641–644.
sequenceDiagram participant N as Noah participant API as Banks API participant M as Marble participant DB as Database N->>API: Transaction event (In, Settled) API->>DB: Store the locked FX rate API->>API: Stop if the crypto leg is already AML-pending API->>M: Decide again (second decision for the same deposit) M-->>API: Outcome — could be Decline rect rgb(253, 226, 225) Note over API,DB: Outcome is only recorded on the row — never checked API->>DB: Promote to Approved and credit the crypto wallet — always end API->>M: Ingest the fiat leg only (best effort)
Same file, ProcessExternalDepositAsync (line 977). A pending deposit is matched to Noah by transaction hash, promoted to Approved and credited without any Marble decision, and only then ingested (lines 1062–1066). The crypto leg that is actually credited (referenceTx) is never sent.
The two builders — BuildMarbleWithdrawTransaction (QuickTransfer.cs 1217–1238) and BuildMarbleTransaction (NoahCallbacks.cs 293–321) — are near copies and fill the same 16 fields.
| Marble field | Value sent | Problem |
|---|---|---|
object_id |
Transaction id | — |
created_at, updated_at |
Row timestamps | — |
cust_id |
Internal customer id | Points at a customer Marble has never received (see MG-04) |
wallet_id |
Customer wallet id | Points at a wallet Marble has never received |
amount, fiat_value |
Amount converted to CAD | Becomes the original amount, still labelled CAD, when the rate is missing (MG-03) |
net_amount |
Same CAD amount | Fees ignored — should be amount minus fee |
transaction_amount |
Same CAD amount, as text | Code comment says "original currency" — the original is lost |
asset |
Always "CAD" |
Original currency is lost |
direction |
In / Out |
— |
status |
Always "Completed" |
Wrong for held, pending, submitted and declined transactions (MG-05) |
transaction_type, transaction_subtype |
e.g. Withdraw / Fiat | — |
payee_name |
SenderName — for withdrawals the payee's nickname (FavouriteName) |
Useless for name screening; the legal name is in BeneficiaryName / HolderName |
source |
SenderAccountNumber |
For withdrawals this is the beneficiary account — belongs in destination |
| Marble field | Where the data already is |
|---|---|
destination |
Withdrawals: SenderAccountNumber / payee IBAN / AccNoorWalletAddress |
fee |
TransactionEntity.Fee / TotalComission |
payment_method |
TransactionEntity.PaymentType, payee PaymentType — a code comment claims it is sent; it is not |
provider_reference |
TransactionReferenceId (Noah id) / TxRef — a code comment claims it is sent; it is not |
country |
CustomerEntity.Country (loaded in both flows) |
payee_bank_country |
PayeeAccountEntity.BankCountry (loaded in Quick Transfer) |
payee_aml_status |
PayeeAccountEntity.Status |
wallet_address |
TransactionEntity.AccNoOrCryptoAddress |
crypto_value |
TransactionEntity.Volume |
tx_hash |
External deposit request TxHash |
ip_address |
The customer's HTTP request in Quick Transfer |
| Marble field | Why it is empty | Requirement |
|---|---|---|
risk_score |
No customer risk-rating model exists yet | GAP-06, FR-451 |
crypto_aml_score |
The KYT result goes to a Sumsub service-bus topic asynchronously and never comes back to Marble | FR-409 |
is_vasp_identified |
Travel Rule data stays in Sumsub | FR-605 |
device_* (5 fields) |
The Banks API does not capture device data | R-050 |
merchant_* (5 fields), batch_payment_id |
Not part of the Banks API flows | FR-208 |
Ordered by severity. Each links to the requirement it breaks in the Requirements tab.
| ID | Gap | Where | Impact | Fix |
|---|---|---|---|---|
| MG-01 | Settlement decision is ignored. The Marble outcome is recorded but never checked; the deposit is always promoted and credited | NoahCallbacks.cs 634–639, 717–725 | A Marble Decline on a settled deposit still puts money in the customer's wallet (FR-504) | Check aml.Block / aml.RequireApproval before promoting; hold instead |
| MG-02 | Most money never reaches Marble. Deposits via TazaPay, Openpayd, Mesta, FiatRepublic and Artha, the FiatRepublic withdrawal, the crypto withdrawal (InsertWithdrawaCryptoTransaction) and account-rejection refunds create transactions with no decision and no ingest |
WebhookApplicationService.* (5 files), AccountsApplicationService*.cs |
Unmonitored flows (FR-501). Marble's 24-hour totals and structuring rules undercount, so large-transaction reports can be missed (FR-551, R-002, R-003) | Route every transaction insert through one shared Marble step |
| MG-03 | Currency conversion fails open. If the CAD rate is missing or the feed errors, the original amount is sent as if it were CAD | QuickTransfer.cs 1251–1265; NoahCallbacks.cs 479–501 | A GBP 8,000 withdrawal (about CAD 14,700) is scored as CAD 8,000 and slips under the CAD 10,000 rules (FR-554, FR-555) | Treat a missing rate as an evaluation failure → hold for approval, and send the rate, source and time |
| ID | Gap | Where | Impact | Fix |
|---|---|---|---|---|
| MG-04 | No customer or wallet records. MarbleCustomer / MarbleWallet are never ingested, although cust_id is sent and a comment says it "must match the customer object ingested into Marble" |
Whole solution | Scenarios cannot use customer type, country, age, KYC state or PEP status (FR-505, FR-452) | Ingest the customer on KYC / Noah customer events and on change; ingest wallets on creation |
| MG-05 | Status is always "Completed". Held, pending, submitted and declined transactions are all sent as completed | QuickTransfer.cs 1231; NoahCallbacks.cs 314 | Aggregations count blocked money as completed; analysts see wrong state | Send the real State / AdminState |
| MG-06 | Lost sends are never retried. TryIngestAsync logs and drops any error |
MarbleComplianceService.cs 140–168 | One Marble hiccup permanently removes a transaction from Marble's history (FR-513, NFR-06, DQ-03) | Queue the ingest in the existing outbox / service bus and retry; reconcile daily against the ledger |
| MG-07 | Original currency and amount are dropped. asset is forced to CAD and transaction_amount repeats the CAD figure |
Both builders | No audit trail of what the customer actually moved, or at what rate (FR-554) | Keep CAD in amount for the scenarios; send the original amount and currency, plus the FX rate, source and time |
| MG-08 | Available fields are not mapped. fee, payment_method, provider_reference, destination, country, payee_bank_country, wallet_address, crypto_value, ip_address — see section 4 |
Both builders | Rules on method, geography, payee country, wallet or IP cannot be written (R-031, R-033, R-045) | One shared mapper that fills every field the entity already has |
| MG-09 | Payee identity is wrong. payee_name is the payee's nickname and the beneficiary account is sent as source |
QuickTransfer.cs 1060–1062, 1234–1235 | Payee screening and destination rules work on the wrong data (FR-301, FR-316) | Send BeneficiaryName / HolderName, and the account as destination |
| MG-10 | Admin decisions never go back to Marble. Approving or rejecting a held withdrawal or deposit does not update Marble | WithdrawApplicationService.StateChange.cs and deposit approval paths |
Marble's case view stays out of date; tuning has no outcome data (FR-859, FR-874) | Re-ingest the updated transaction on every admin state change |
| ID | Gap | Where | Impact | Fix |
|---|---|---|---|---|
| MG-11 | Duplicate decisions. A re-delivered Noah webhook is decided again, and every Noah deposit is decided twice (deposit, then settlement) | NoahCallbacks.cs 196, 634 | Duplicate decisions and cases for one deposit (FR-854, DQ-02) | Skip the decision when the row already has a ComplianceStatus for that stage |
| MG-12 | 30-second timeout on a synchronous call inside a customer request and a webhook | MarbleOptions.Timeout default, Program.cs 160–161 |
A slow Marble stalls withdrawals for up to 30 s (NFR-02: p99 under 2 s) | Set TimeoutSeconds to about 3; the fail-safe already holds on timeout |
| MG-13 | Only transactions are decided. No onboarding decision and no name screening | Whole solution | Customers and payees are never screened in Marble (FR-303, FR-316) | Add customer and payee decisions once Marble Screening is licensed |
| MG-14 | Tests cover the on/off switch only. The 12 Marble tests check tenant enablement; none check the payload or that a block actually holds money | Banks.API.Tests/Tests/MarbleComplianceGateTests.cs |
MG-01 and MG-05 went unnoticed (T-03d, T-14) | Add payload-mapping tests and "Decline must not credit" tests per flow |
| MG-15 | Data residency unconfirmed. The fallback base URL is Checkmarble's hosted service; the real value comes from CoreConfig | appsettings.json, Program.cs 143–145 |
Customer data may leave Canada (NFR-12, A-02) | Confirm production CoreConfig points to the self-hosted Canadian instance |
MarbleComplianceService, with a per-tenant switch that fails closed.ComplianceStatus and ComplianceScore give admins the Marble result on each transaction.MG-03, MG-05, MG-07, MG-08 and MG-09 can all be fixed in one place: a single mapper used by every flow, replacing the two duplicated builders. A sketch using only fields that exist today on TransactionEntity, CustomerEntity and PayeeAccountEntity:
public static MarbleTransaction ToMarble(
TransactionEntity txn, CustomerEntity? customer, PayeeAccountEntity? payee,
CadValue cad, string direction, string? ipAddress)
{
bool outbound = direction == BanksConstants.DirectionOut;
return new MarbleTransaction
{
ObjectId = txn.Id.ToString(),
CreatedAt = ToUtc(txn.CreatedDate),
UpdatedAt = ToUtc(txn.ModifiedDate ?? txn.CreatedDate),
CustId = txn.CustomerId?.ToString(),
WalletId = txn.CustomerWalletId?.ToString(),
Amount = cad.Amount, // scenarios stay in CAD
FiatValue = cad.Amount,
Fee = txn.TotalComission ?? txn.Fee,
NetAmount = cad.Amount - cad.ToCad(txn.TotalComission ?? txn.Fee ?? 0m),
Asset = "CAD",
TransactionAmount = $"{txn.Amount} {txn.TxWallet}", // original amount + currency
CryptoValue = txn.Volume,
Direction = direction,
Status = txn.State, // real state, not "Completed"
TransactionType = txn.TxType,
TransactionSubtype = txn.TxSubType,
PaymentMethod = txn.PaymentType ?? payee?.PaymentType,
ProviderReference = txn.TransactionReferenceId ?? txn.TxRef,
PayeeName = payee?.BeneficiaryName ?? payee?.HolderName ?? txn.SenderName,
PayeeBankCountry = payee?.BankCountry,
PayeeAmlStatus = payee?.Status,
Source = outbound ? null : txn.SenderAccountNumber,
Destination = outbound ? payee?.IBAN ?? txn.SenderAccountNumber : null,
WalletAddress = txn.AccNoOrCryptoAddress,
Country = customer?.Country,
IpAddress = ipAddress,
};
}
CadValue carries the converted amount together with the rate, its source and its timestamp. When no rate is available it fails the evaluation (hold for approval) rather than passing the original amount through. The rate, source and timestamp — and any other attribute Marble has no field for, such as purpose — need new fields in Marble's data model and in the ArthaMarble package (a 0.1.2 release), because MarbleTransaction is sealed.
| Step | Fixes | Why first |
|---|---|---|
| 1 | MG-01 — honour the settlement decision; MG-03 — hold on a missing rate | Small code changes that stop money moving against a Marble verdict or on a wrong amount |
| 2 | MG-05, MG-07, MG-08, MG-09 — one shared mapper (section 6) | One change fixes most of the data quality |
| 3 | MG-02 — call the mapper from every provider flow | Gives Marble the full transaction history its aggregate rules depend on |
| 4 | MG-04 — ingest customers and wallets | Unlocks customer-level rules and, later, screening (MG-13) |
| 5 | MG-06, MG-10, MG-11 — outbox with retries, sync admin decisions, stop duplicates | Makes Marble's copy complete and trustworthy |
| 6 | MG-12, MG-14, MG-15 — timeout, tests, residency check | Hardening and evidence for examiners |