Banking apps
This is the page for a team building a banking product: a neobank, a budgeting app, a card programme, a credit union's digital banking, an accounting tool. You send the Understanding API the transactions you already have, and it answers closed questions about them: is this merchant restricted, which charge does this receipt belong to, which transaction is the refund for this purchase. Each answer has a probability, reason codes and an abstain flag.
Not open yet. Opens with early access.
On this page
Before you start
- An API key from the console, one per service so you can revoke one alone. You need an account first. Call the API from your server and never from a browser or a mobile app.
- A way to get transactions. An aggregator, a card processor, a statement file, or your own ledger. The API does not connect to a bank.
- A policy for the abstain case. Decide in advance what your product does when the answer is "a person should look". The patterns below show three.
- Not for credit decisions. These are questions about merchants and matching. Do not use an answer for credit, employment or insurance eligibility.
Will it work with my bank?
We don't connect to banks. We answer closed questions about transactions you send us, so it works with any bank whose transactions reach you, through an aggregator, a card processor or a statement file, as long as each transaction has a merchant description and an amount. Card transactions that carry a merchant category code give us more to go on than bank-feed text, and when the evidence is too thin we abstain instead of guessing. Whether a particular bank or credit union can call us from inside its own systems depends on its core, processor and vendor-approval process; we will tell you which path applies, and we do not claim every bank today.
Decide first: synchronous or asynchronous
Use it asynchronously unless you have a reason not to.
| Where the question comes up | Mode | Why |
|---|---|---|
| An alert after a purchase posts, a spending control, a weekly review | Asynchronous | Nothing is waiting. Take the time and batch. |
| Receipt matching and refund pairing | Asynchronous | They run when the receipt or the refund arrives. |
| Categorizing a statement or a bank feed | Asynchronous | Aggregators refresh a few times a day, so there is no rush. |
| Deciding whether to approve a card at the moment of purchase | Synchronous, only from a cache | A model call is too slow to wait for. Answer from a merchant-level cache and fail open or closed on a miss. |
Speed, stated plainly:
- A rule check for the four original decisions, when a rule settles a case by definition, runs in microseconds. We measured that in-process on our own bench. The five newer decisions have not been timed. Those cases cost nothing and skip the model.
- A model call takes on the order of half a second, according to an independent test of the underlying model. That is a third party's figure.
- Latency for the hosted API isn't measured: it isn't open yet. Card processors give you between 2 and 6 seconds to answer an authorization, so do not put a model call on that path without a cache and a timeout.
Where it plugs in
The effort column is our estimate, not a measured figure. Where a source's own documentation doesn't settle a field, the cell says so.
| Source | What it gives you | Send us | Timing | Pattern |
|---|---|---|---|---|
| Plaid Transactions | Webhook SYNC_UPDATES_AVAILABLE, then /transactions/sync. Names, merchant_name, original_description (opt in), merchant_category_code (beta, mostly card transactions), pending. | The description, the merchant name, amount, date, and the code when it is there. | Plaid checks for new data roughly one to four times a day. | Asynchronous. Days to build. |
| Plaid Enrich | Cleans a descriptor for your own ledger or non-Plaid data, up to 100 per call. Returns counterparties and categories, not a code. | The cleaned name to us afterward. | On demand. | Complementary: enrich, then ask us. |
| MX | merchant_category_code (ISO 18245, an integer), description, original_description, memo. MX's documentation doesn't say whether the code is filled for bank-feed data. | original_description, else description; the code when present. | Asynchronous. | Asynchronous job. Days. |
| Akoya (FDX) | description, memo, payee, reference, category, status. There is no code field; category may hold one. | The descriptive fields; category as the code only if it is exactly four digits. | Asynchronous. | Asynchronous job. |
| Mastercard Open Finance (Finicity) | Categorization that varies by account type and institution. Its documentation doesn't settle the field names. | The descriptive fields, once you confirm their names. | Asynchronous. | Asynchronous job. |
| Stripe Issuing | issuing_authorization.request with merchant_data.name, .category_code, .network_id. | Name and code. | Respond within 2 seconds, or your timeout settings or Autopilot decide. | Synchronous from a cache. Weeks. |
| Lithic | The authorization stream carries the merchant descriptor and the code. Lithic's documentation doesn't settle the field paths. | Descriptor and code. | 6 seconds hard, 3 recommended. Slower responses raise voids after approval. | Synchronous from a cache. |
| Marqeta | Just-in-time funding. Marqeta's documentation doesn't say whether the code is in the payload. | Descriptor. | 3 seconds, then the platform declines. | Synchronous from a cache. |
| Galileo | Authorization API with an mcc field. | Descriptor and code. | A 2 second guideline. A fallback applies on failure. | Synchronous from a cache. |
| Whop card stack | card_transaction webhooks with merchant_name, merchant_category_code (can be null), declined_reason, status. Whop's documentation shows no synchronous approve or decline hook. | The merchant name and code. | Asynchronous. | Asynchronous. |
| Statement files | OFX, QFX and CSV. OFX has NAME or PAYEE and MEMO and no code. CSV columns vary by bank. | The description column. | A file. | Batch. Days, and the thinnest fields. |
| Cores and digital banking | Jack Henry (jXchange, SymXchange, Banno), Fiserv (Banking Hub), Corelation (KeyBridge). | Per institution. | Per institution. | Months, with per-institution contracts. |
| Your own ledger | Rows you post to us over HTTPS. | Merchant, amount, date, id, code. | You choose. | Days. |
Field mapping
Send a merchant descriptor, an amount in US dollars, an ISO 8601 date, your own id for the row, and a four-digit merchant category code when you have one. Never send account numbers, card numbers, names or balances, and never invent a code. A purchase is positive and a refund or credit is negative.
| Source field | Send as | Notes |
|---|---|---|
Plaid original_description, else name | merchant | original_description is the closest to the statement text. Add merchant_name in front when it adds information, up to 200 characters in all. |
Plaid amount | amount | Plaid counts money out as positive, which matches ours. Verify against your own data. |
Plaid date | date | |
Plaid transaction_id | id | A pending transaction and its posted version have different ids, so deduplicate by merchant, not id. |
Plaid merchant_category_code | mcc | Pad to four digits. Null is normal. Drop anything outside 0 to 9999. |
Stripe merchant_data.name and .category_code | merchant and mcc | The merchant question does not use the amount. |
OFX NAME or PAYEE, then MEMO | merchant | OFX counts money out as negative, so flip the sign. |
| CSV description column | merchant | The sign depends on the bank: Chase, Bank of America and Wells Fargo list money out as negative; American Express and Apple Card list purchases as positive; split debit and credit columns mean debit minus credit. |
Digit runs of nine or more should be removed from a descriptor before it leaves your service. The API refuses a request that looks like it holds a card number, a Social Security number, or an account number beside a routing number, and answers 400 with error.code of sensitive_data and the field. It does not repeat the value.
One call
curl https://api.theunderstanding.company/v1/understand \ -H "Authorization: Bearer $UNDERSTANDING_API_KEY" \ -H "Idempotency-Key: txn_9f3a21" \ -H "Content-Type: application/json" \ -d '{"decision":"restricted_merchant","input":{"merchant":"DRAFTKINGS","mcc":"7995"}}'
The reply is the envelope from Decisions: answer, probability, reasons, abstain. The x-request-id header names the call; log it with the decision you made.
The SDKs
There is a TypeScript package, @theunderstanding/sdk, and a Python package, understanding-api. Not open yet. Opens with early access. Both have no dependencies, retry only on 429, 502, 503 and network errors with backoff, send an Idempotency-Key on every call and reuse it across retries, and raise typed errors.
import { Understanding } from "@theunderstanding/sdk"; const u = new Understanding({ apiKey: process.env.UNDERSTANDING_API_KEY }); const r = await u.restrictedMerchant({ merchant: "DRAFTKINGS", mcc: "7995" }); if (r.needsReview) { console.log("A person decides", r.requestId, r.reasons); } else if (r.answer) { console.log("Restricted", r.probability); }
import os from understanding import Understanding u = Understanding(api_key=os.environ["UNDERSTANDING_API_KEY"]) r = u.restricted_merchant("DRAFTKINGS", mcc="7995") if r.needs_review: print("A person decides", r.request_id, r.reasons) elif r.answer: print("Restricted", r.probability)
needsReview is true when abstain is set or there is no answer. The SDK reads UNDERSTANDING_API_KEY and UNDERSTANDING_API_URL from the environment when you do not pass them.
Batch: send many at once
POST /v1/understand/batch takes { "requests": [ ... ] } with 1 to 100 ordinary request bodies and returns { "results": [ { "status": 200, "body": { ... } } ] } in the same order. A batch of 100 distinct merchants is one round trip.
import { Understanding, requests } from "@theunderstanding/sdk"; const u = new Understanding({ apiKey: process.env.UNDERSTANDING_API_KEY }); // Deduplicate first: each merchant and code once, not once per row. const merchants = [ { merchant: "DRAFTKINGS", mcc: "7995" }, { merchant: "LUCKY COINS LLC", mcc: "5816" }, { merchant: "TRADER JOES #123", mcc: "5411" }, ]; const outcomes = await u.batch(merchants.map((m) => requests.restrictedMerchant(m)), { retryFailed: 3 }); for (const o of outcomes) { const m = merchants[o.index]; if (!o.ok) console.log("review", m.merchant, o.error.code); else if (o.result.needsReview) console.log("review", m.merchant, o.result.probability); else console.log(o.result.answer ? "flag" : "clear", m.merchant); }
import os from understanding import Understanding, restricted_merchant_request u = Understanding(api_key=os.environ["UNDERSTANDING_API_KEY"]) # Deduplicate first: each merchant and code once, not once per row. merchants = [ ("DRAFTKINGS", "7995"), ("LUCKY COINS LLC", "5816"), ("TRADER JOES #123", "5411"), ] outcomes = u.batch([restricted_merchant_request(name, mcc=mcc) for name, mcc in merchants], retry_failed=3) for o in outcomes: name = merchants[o.index][0] if not o.ok: print("review", name, o.error.code) elif o.result.needs_review: print("review", name, o.result.probability) else: print("flag" if o.result.answer else "clear", name)
How a batch is counted matters:
- Each item counts toward the rate limit, and each item is metered like a single call. The limit is 60 requests a minute per key. On a default key, a batch of more than 60 has items that come back as their own 429 inside a normal 200 response.
- A bad item gets its own 400. It does not fail the batch. Never resend it unchanged.
- Resend only the items that failed with 429, 502 or 503, with a new
Idempotency-Key. The SDKs do this when you passretryFailed(retry_failedin Python). Resending the whole batch with the same key would replay the stored 429s. - A batch can be up to 1 MB.
If you need more than 60 a minute, write to api@theunderstanding.company.
Idempotency
Send an Idempotency-Key on every write you might retry. Use something that names the thing you are asking about, such as your transaction id. The same key within 24 hours returns the first answer, with the header idempotent-replayed: true, and makes no second model call and no second charge. A key reused with a different body returns 422. This is what makes an at-least-once webhook safe: a redelivery asks the same question and gets the stored answer. See Rate limits.
Send abstain to review
The abstain flag is the point of the product. Whatever your app does with an answer, an abstain is not a yes and not a no.
| What came back | What your code does |
|---|---|
abstain: false, answer: true | Flag it. Apply your policy, for example an alert or a control. |
abstain: false, answer: false | Clear it. This is not proof the merchant is fine. |
abstain: true, or answer: null | Send it to a person with the reasons and the x-request-id. Never clear it. |
| A 400 for one item | Fix the field named in error.field. Do not clear the item. |
| A 429, 502 or 503 for one item | Retry it. If it still fails, send it to review. |
| The API is unreachable | Send the whole group to review. |
abstainBelow moves the line for one request, from 0.5 to 0.99, and the default is 0.8. Lowering it sends fewer cases to a person, so lower it only after you have checked what slips through. The probability is the model's own and is not calibrated, so choose your line by looking at your own data. See Probabilities, reasons and abstain.
Keep the reason codes, the probability, the abstain flag and the request id with every decision you act on. If a customer disputes a decision, those are what let you explain it.
Fail open or fail closed
If you do put a decision near the point of purchase, design for the case where the model is slow or down.
- Answer from a cache. The question "is this merchant a restricted one" is about the merchant, not the cardholder or the purchase, so it can be cached by normalized descriptor, code and network id. A cached merchant never waits for the model.
- Cap the wait for a merchant you have not seen. Use a hard timeout well under the processor's, such as 1.5 seconds. Our reference code defaults to that, which is under Stripe's and Galileo's 2 seconds.
- Make the timeout answer an abstain, and decide in writing what an abstain means: fail open (approve, and queue a review) or fail closed (decline). The reference code defaults to open and lets you set either.
- Let the late answer fill the cache. The call keeps running after the timeout, so the next purchase at that merchant is instant.
- Warm the cache from merchants you already know, instead of waiting for them to appear at a till.
A wrong decline at a till is a customer-facing error. Keep the reasons and the request id so that it can be explained and reversed.
Reference implementations
Three small TypeScript files you can copy into a service. Not open yet. Opens with early access. Each has no imports, takes its dependencies through small interfaces, and comes with a test. The tests say nothing about the hosted API's accuracy or speed.
| File | What it does |
|---|---|
plaid-sync-webhook.ts | On SYNC_UPDATES_AVAILABLE, pages /transactions/sync from a stored cursor, keeps money-out rows, maps each to a request, deduplicates by merchant and code, sends batches of 100, and sorts each result into flag, review or clear. It advances the cursor only after your sinks succeed, so a crash re-delivers the page. An abstain, a per-item error, and an unreachable API all end in review. |
card-auth-handler.ts | A decision for a card authorization hook. It keeps a merchant-level cache (24 hours for a definite answer, 5 minutes for an abstain), never makes a cached merchant wait, caps an uncached one at 1.5 seconds, answers abstain on timeout, and applies the fail-open or fail-closed setting you choose. It never throws. It has adapters for Stripe Issuing and for a Lithic-style request. |
statement-classifier.ts | Reads a bank CSV, handles the sign convention for each bank, groups by merchant and code, sends batches of 100, and returns flagged, review and cleared lists. It uses the same column and sign rules as the Mac tools. |
A Plaid webhook URL is public, so verify it. Anyone who finds it can post a valid-looking SYNC_UPDATES_AVAILABLE and cost you a full sync and API spend. The webhook handler fails closed: with no verifier it answers 500 and does nothing. It includes a worked check of Plaid's Plaid-Verification header. Test it with your own Plaid keys before you rely on it.
What a vendor-risk reviewer will ask
Answers we can give today, and the ones we cannot.
- Which fields do you receive? The question and the fields it needs: a merchant descriptor, an amount, a date, an id, and a category code. The API refuses card, Social Security and account numbers.
- Do you keep the payload? Our logs do not carry your input. We keep a usage record for each call: the account and key, the decision, the number of input tokens, whether it abstained, the status and the time. A call with an
Idempotency-Keystores the answer and a hash of the request, not the input, for at least 24 hours (removed on a later keyed request). See Data and privacy. - Are prompts retained or used for training? Requests are routed only to providers we ask to retain nothing and not to train on prompts. That is requested on every call.
- Do you have a SOC 2 report? No, and we make no compliance claim. Do not assume one. Ask us at api@theunderstanding.company before you send real data if you need a specific assurance.
- How accurate is it, and how fast? Not measured for the hosted API, which isn't open yet. The numbers we have are from our own rule engine on our own datasets and are not accuracy rates.
- What should we do on an abstain, an error or a timeout? The two sections above.
- Is there a non-AI fallback? No. If the model does not answer you get a 502 and the call is not billed. Keep your own fallback: your rules or a review queue.
# Banking apps
This is the page for a team building a banking product: a neobank, a budgeting app, a card programme, a credit union's digital banking, an accounting tool. You send the Understanding API the transactions you already have, and it answers closed questions about them: is this merchant restricted, which charge does this receipt belong to, which transaction is the refund for this purchase. Each answer has a probability, reason codes and an abstain flag.
Not open yet. Opens with early access.
## Before you start
- An API key from the console, one per service so you can revoke one alone. You need an account first. Call the API from your server and never from a browser or a mobile app.
- A way to get transactions. An aggregator, a card processor, a statement file, or your own ledger. The API does not connect to a bank.
- A policy for the abstain case. Decide in advance what your product does when the answer is "a person should look". The patterns below show three.
- Not for credit decisions. These are questions about merchants and matching. Do not use an answer for credit, employment or insurance eligibility.
## Will it work with my bank?
We don't connect to banks. We answer closed questions about transactions you send us, so it works with any bank whose transactions reach you, through an aggregator, a card processor or a statement file, as long as each transaction has a merchant description and an amount. Card transactions that carry a merchant category code give us more to go on than bank-feed text, and when the evidence is too thin we abstain instead of guessing. Whether a particular bank or credit union can call us from inside its own systems depends on its core, processor and vendor-approval process; we will tell you which path applies, and we do not claim every bank today.
## Decide first: synchronous or asynchronous
Use it asynchronously unless you have a reason not to.
| Where the question comes up | Mode | Why |
| --- | --- | --- |
| An alert after a purchase posts, a spending control, a weekly review | Asynchronous | Nothing is waiting. Take the time and batch. |
| Receipt matching and refund pairing | Asynchronous | They run when the receipt or the refund arrives. |
| Categorizing a statement or a bank feed | Asynchronous | Aggregators refresh a few times a day, so there is no rush. |
| Deciding whether to approve a card at the moment of purchase | Synchronous, only from a cache | A model call is too slow to wait for. Answer from a merchant-level cache and fail open or closed on a miss. |
Speed, stated plainly:
- A rule check for the four original decisions, when a rule settles a case by definition, runs in microseconds. We measured that in-process on our own bench. The five newer decisions have not been timed. Those cases cost nothing and skip the model.
- A model call takes on the order of half a second, according to an independent test of the underlying model. That is a third party's figure.
- Latency for the hosted API isn't measured: it isn't open yet. Card processors give you between 2 and 6 seconds to answer an authorization, so do not put a model call on that path without a cache and a timeout.
## Where it plugs in
The effort column is our estimate, not a measured figure. Where a source's own documentation doesn't settle a field, the cell says so.
| Source | What it gives you | Send us | Timing | Pattern |
| --- | --- | --- | --- | --- |
| Plaid Transactions | Webhook `SYNC_UPDATES_AVAILABLE`, then `/transactions/sync`. Names, `merchant_name`, `original_description` (opt in), `merchant_category_code` (beta, mostly card transactions), `pending`. | The description, the merchant name, amount, date, and the code when it is there. | Plaid checks for new data roughly one to four times a day. | Asynchronous. Days to build. |
| Plaid Enrich | Cleans a descriptor for your own ledger or non-Plaid data, up to 100 per call. Returns counterparties and categories, not a code. | The cleaned name to us afterward. | On demand. | Complementary: enrich, then ask us. |
| MX | `merchant_category_code` (ISO 18245, an integer), `description`, `original_description`, `memo`. MX's documentation doesn't say whether the code is filled for bank-feed data. | `original_description`, else `description`; the code when present. | Asynchronous. | Asynchronous job. Days. |
| Akoya (FDX) | `description`, `memo`, `payee`, `reference`, `category`, `status`. There is no code field; `category` may hold one. | The descriptive fields; `category` as the code only if it is exactly four digits. | Asynchronous. | Asynchronous job. |
| Mastercard Open Finance (Finicity) | Categorization that varies by account type and institution. Its documentation doesn't settle the field names. | The descriptive fields, once you confirm their names. | Asynchronous. | Asynchronous job. |
| Stripe Issuing | `issuing_authorization.request` with `merchant_data.name`, `.category_code`, `.network_id`. | Name and code. | Respond within 2 seconds, or your timeout settings or Autopilot decide. | Synchronous from a cache. Weeks. |
| Lithic | The authorization stream carries the merchant descriptor and the code. Lithic's documentation doesn't settle the field paths. | Descriptor and code. | 6 seconds hard, 3 recommended. Slower responses raise voids after approval. | Synchronous from a cache. |
| Marqeta | Just-in-time funding. Marqeta's documentation doesn't say whether the code is in the payload. | Descriptor. | 3 seconds, then the platform declines. | Synchronous from a cache. |
| Galileo | Authorization API with an `mcc` field. | Descriptor and code. | A 2 second guideline. A fallback applies on failure. | Synchronous from a cache. |
| Whop card stack | `card_transaction` webhooks with `merchant_name`, `merchant_category_code` (can be null), `declined_reason`, `status`. Whop's documentation shows no synchronous approve or decline hook. | The merchant name and code. | Asynchronous. | Asynchronous. |
| Statement files | OFX, QFX and CSV. OFX has `NAME` or `PAYEE` and `MEMO` and no code. CSV columns vary by bank. | The description column. | A file. | Batch. Days, and the thinnest fields. |
| Cores and digital banking | Jack Henry (jXchange, SymXchange, Banno), Fiserv (Banking Hub), Corelation (KeyBridge). | Per institution. | Per institution. | Months, with per-institution contracts. |
| Your own ledger | Rows you post to us over HTTPS. | Merchant, amount, date, id, code. | You choose. | Days. |
## Field mapping
Send a merchant descriptor, an amount in US dollars, an ISO 8601 date, your own id for the row, and a four-digit merchant category code when you have one. Never send account numbers, card numbers, names or balances, and never invent a code. A purchase is positive and a refund or credit is negative.
| Source field | Send as | Notes |
| --- | --- | --- |
| Plaid `original_description`, else `name` | `merchant` | `original_description` is the closest to the statement text. Add `merchant_name` in front when it adds information, up to 200 characters in all. |
| Plaid `amount` | `amount` | Plaid counts money out as positive, which matches ours. Verify against your own data. |
| Plaid `date` | `date` | |
| Plaid `transaction_id` | `id` | A pending transaction and its posted version have different ids, so deduplicate by merchant, not id. |
| Plaid `merchant_category_code` | `mcc` | Pad to four digits. Null is normal. Drop anything outside 0 to 9999. |
| Stripe `merchant_data.name` and `.category_code` | `merchant` and `mcc` | The merchant question does not use the amount. |
| OFX `NAME` or `PAYEE`, then `MEMO` | `merchant` | OFX counts money out as negative, so flip the sign. |
| CSV description column | `merchant` | The sign depends on the bank: Chase, Bank of America and Wells Fargo list money out as negative; American Express and Apple Card list purchases as positive; split debit and credit columns mean debit minus credit. |
Digit runs of nine or more should be removed from a descriptor before it leaves your service. The API refuses a request that looks like it holds a card number, a Social Security number, or an account number beside a routing number, and answers 400 with `error.code` of `sensitive_data` and the field. It does not repeat the value.
## One call
```
curl https://api.theunderstanding.company/v1/understand \
-H "Authorization: Bearer $UNDERSTANDING_API_KEY" \
-H "Idempotency-Key: txn_9f3a21" \
-H "Content-Type: application/json" \
-d '{"decision":"restricted_merchant","input":{"merchant":"DRAFTKINGS","mcc":"7995"}}'
```
The reply is the envelope from [Decisions](/api/docs/decisions): `answer`, `probability`, `reasons`, `abstain`. The `x-request-id` header names the call; log it with the decision you made.
## The SDKs
There is a TypeScript package, `@theunderstanding/sdk`, and a Python package, `understanding-api`. Not open yet. Opens with early access. Both have no dependencies, retry only on 429, 502, 503 and network errors with backoff, send an `Idempotency-Key` on every call and reuse it across retries, and raise typed errors.
The TypeScript example:
```
import { Understanding } from "@theunderstanding/sdk";
const u = new Understanding({ apiKey: process.env.UNDERSTANDING_API_KEY });
const r = await u.restrictedMerchant({ merchant: "DRAFTKINGS", mcc: "7995" });
if (r.needsReview) {
console.log("A person decides", r.requestId, r.reasons);
} else if (r.answer) {
console.log("Restricted", r.probability);
}
```
The Python example:
```
import os
from understanding import Understanding
u = Understanding(api_key=os.environ["UNDERSTANDING_API_KEY"])
r = u.restricted_merchant("DRAFTKINGS", mcc="7995")
if r.needs_review:
print("A person decides", r.request_id, r.reasons)
elif r.answer:
print("Restricted", r.probability)
```
`needsReview` is true when `abstain` is set or there is no answer. The SDK reads `UNDERSTANDING_API_KEY` and `UNDERSTANDING_API_URL` from the environment when you do not pass them.
## Batch: send many at once
`POST /v1/understand/batch` takes `{ "requests": [ ... ] }` with 1 to 100 ordinary request bodies and returns `{ "results": [ { "status": 200, "body": { ... } } ] }` in the same order. A batch of 100 distinct merchants is one round trip.
The TypeScript example:
```
import { Understanding, requests } from "@theunderstanding/sdk";
const u = new Understanding({ apiKey: process.env.UNDERSTANDING_API_KEY });
// Deduplicate first: each merchant and code once, not once per row.
const merchants = [
{ merchant: "DRAFTKINGS", mcc: "7995" },
{ merchant: "LUCKY COINS LLC", mcc: "5816" },
{ merchant: "TRADER JOES #123", mcc: "5411" },
];
const outcomes = await u.batch(merchants.map((m) => requests.restrictedMerchant(m)), { retryFailed: 3 });
for (const o of outcomes) {
const m = merchants[o.index];
if (!o.ok) console.log("review", m.merchant, o.error.code);
else if (o.result.needsReview) console.log("review", m.merchant, o.result.probability);
else console.log(o.result.answer ? "flag" : "clear", m.merchant);
}
```
The Python example:
```
import os
from understanding import Understanding, restricted_merchant_request
u = Understanding(api_key=os.environ["UNDERSTANDING_API_KEY"])
# Deduplicate first: each merchant and code once, not once per row.
merchants = [
("DRAFTKINGS", "7995"),
("LUCKY COINS LLC", "5816"),
("TRADER JOES #123", "5411"),
]
outcomes = u.batch([restricted_merchant_request(name, mcc=mcc) for name, mcc in merchants], retry_failed=3)
for o in outcomes:
name = merchants[o.index][0]
if not o.ok:
print("review", name, o.error.code)
elif o.result.needs_review:
print("review", name, o.result.probability)
else:
print("flag" if o.result.answer else "clear", name)
```
How a batch is counted matters:
- Each item counts toward the rate limit, and each item is metered like a single call. The limit is 60 requests a minute per key. On a default key, a batch of more than 60 has items that come back as their own 429 inside a normal 200 response.
- A bad item gets its own 400. It does not fail the batch. Never resend it unchanged.
- Resend only the items that failed with 429, 502 or 503, with a new `Idempotency-Key`. The SDKs do this when you pass `retryFailed` (`retry_failed` in Python). Resending the whole batch with the same key would replay the stored 429s.
- A batch can be up to 1 MB.
If you need more than 60 a minute, write to api@theunderstanding.company.
## Idempotency
Send an `Idempotency-Key` on every write you might retry. Use something that names the thing you are asking about, such as your transaction id. The same key within 24 hours returns the first answer, with the header `idempotent-replayed: true`, and makes no second model call and no second charge. A key reused with a different body returns 422. This is what makes an at-least-once webhook safe: a redelivery asks the same question and gets the stored answer. See [Rate limits](/api/docs/limits#rate-limits).
## Send abstain to review
The abstain flag is the point of the product. Whatever your app does with an answer, an abstain is not a yes and not a no.
| What came back | What your code does |
| --- | --- |
| `abstain: false`, `answer: true` | Flag it. Apply your policy, for example an alert or a control. |
| `abstain: false`, `answer: false` | Clear it. This is not proof the merchant is fine. |
| `abstain: true`, or `answer: null` | Send it to a person with the reasons and the `x-request-id`. Never clear it. |
| A 400 for one item | Fix the field named in `error.field`. Do not clear the item. |
| A 429, 502 or 503 for one item | Retry it. If it still fails, send it to review. |
| The API is unreachable | Send the whole group to review. |
`abstainBelow` moves the line for one request, from 0.5 to 0.99, and the default is 0.8. Lowering it sends fewer cases to a person, so lower it only after you have checked what slips through. The probability is the model's own and is not calibrated, so choose your line by looking at your own data. See [Probabilities, reasons and abstain](/api/docs/abstain).
Keep the reason codes, the probability, the abstain flag and the request id with every decision you act on. If a customer disputes a decision, those are what let you explain it.
## Fail open or fail closed
If you do put a decision near the point of purchase, design for the case where the model is slow or down.
1. Answer from a cache. The question "is this merchant a restricted one" is about the merchant, not the cardholder or the purchase, so it can be cached by normalized descriptor, code and network id. A cached merchant never waits for the model.
2. Cap the wait for a merchant you have not seen. Use a hard timeout well under the processor's, such as 1.5 seconds. Our reference code defaults to that, which is under Stripe's and Galileo's 2 seconds.
3. Make the timeout answer an abstain, and decide in writing what an abstain means: fail open (approve, and queue a review) or fail closed (decline). The reference code defaults to open and lets you set either.
4. Let the late answer fill the cache. The call keeps running after the timeout, so the next purchase at that merchant is instant.
5. Warm the cache from merchants you already know, instead of waiting for them to appear at a till.
A wrong decline at a till is a customer-facing error. Keep the reasons and the request id so that it can be explained and reversed.
## Reference implementations
Three small TypeScript files you can copy into a service. Not open yet. Opens with early access. Each has no imports, takes its dependencies through small interfaces, and comes with a test. The tests say nothing about the hosted API's accuracy or speed.
| File | What it does |
| --- | --- |
| `plaid-sync-webhook.ts` | On `SYNC_UPDATES_AVAILABLE`, pages `/transactions/sync` from a stored cursor, keeps money-out rows, maps each to a request, deduplicates by merchant and code, sends batches of 100, and sorts each result into flag, review or clear. It advances the cursor only after your sinks succeed, so a crash re-delivers the page. An abstain, a per-item error, and an unreachable API all end in review. |
| `card-auth-handler.ts` | A decision for a card authorization hook. It keeps a merchant-level cache (24 hours for a definite answer, 5 minutes for an abstain), never makes a cached merchant wait, caps an uncached one at 1.5 seconds, answers abstain on timeout, and applies the fail-open or fail-closed setting you choose. It never throws. It has adapters for Stripe Issuing and for a Lithic-style request. |
| `statement-classifier.ts` | Reads a bank CSV, handles the sign convention for each bank, groups by merchant and code, sends batches of 100, and returns flagged, review and cleared lists. It uses the same column and sign rules as the Mac tools. |
A Plaid webhook URL is public, so verify it. Anyone who finds it can post a valid-looking `SYNC_UPDATES_AVAILABLE` and cost you a full sync and API spend. The webhook handler fails closed: with no verifier it answers 500 and does nothing. It includes a worked check of Plaid's `Plaid-Verification` header. Test it with your own Plaid keys before you rely on it.
## What a vendor-risk reviewer will ask
Answers we can give today, and the ones we cannot.
- Which fields do you receive? The question and the fields it needs: a merchant descriptor, an amount, a date, an id, and a category code. The API refuses card, Social Security and account numbers.
- Do you keep the payload? Our logs do not carry your input. We keep a usage record for each call: the account and key, the decision, the number of input tokens, whether it abstained, the status and the time. A call with an `Idempotency-Key` stores the answer and a hash of the request, not the input, for at least 24 hours (removed on a later keyed request). See [Data and privacy](/api/docs/data).
- Are prompts retained or used for training? Requests are routed only to providers we ask to retain nothing and not to train on prompts. That is requested on every call.
- Do you have a SOC 2 report? No, and we make no compliance claim. Do not assume one. Ask us at api@theunderstanding.company before you send real data if you need a specific assurance.
- How accurate is it, and how fast? Not measured for the hosted API, which isn't open yet. The numbers we have are from our own rule engine on our own datasets and are not accuracy rates.
- What should we do on an abstain, an error or a timeout? The two sections above.
- Is there a non-AI fallback? No. If the model does not answer you get a 502 and the call is not billed. Keep your own fallback: your rules or a review queue.
Source: /api/docs/integrations/banking-apps