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
    Where the question comes upModeWhy
    An alert after a purchase posts, a spending control, a weekly reviewAsynchronousNothing is waiting. Take the time and batch.
    Receipt matching and refund pairingAsynchronousThey run when the receipt or the refund arrives.
    Categorizing a statement or a bank feedAsynchronousAggregators refresh a few times a day, so there is no rush.
    Deciding whether to approve a card at the moment of purchaseSynchronous, only from a cacheA 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
    SourceWhat it gives youSend usTimingPattern
    Plaid TransactionsWebhook 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 EnrichCleans 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.
    MXmerchant_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 Issuingissuing_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.
    LithicThe 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.
    MarqetaJust-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.
    GalileoAuthorization API with an mcc field.Descriptor and code.A 2 second guideline. A fallback applies on failure.Synchronous from a cache.
    Whop card stackcard_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 filesOFX, 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 bankingJack Henry (jXchange, SymXchange, Banno), Fiserv (Banking Hub), Corelation (KeyBridge).Per institution.Per institution.Months, with per-institution contracts.
    Your own ledgerRows 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
    Source fieldSend asNotes
    Plaid original_description, else namemerchantoriginal_description is the closest to the statement text. Add merchant_name in front when it adds information, up to 200 characters in all.
    Plaid amountamountPlaid counts money out as positive, which matches ours. Verify against your own data.
    Plaid datedate
    Plaid transaction_ididA pending transaction and its posted version have different ids, so deduplicate by merchant, not id.
    Plaid merchant_category_codemccPad to four digits. Null is normal. Drop anything outside 0 to 9999.
    Stripe merchant_data.name and .category_codemerchant and mccThe merchant question does not use the amount.
    OFX NAME or PAYEE, then MEMOmerchantOFX counts money out as negative, so flip the sign.
    CSV description columnmerchantThe 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

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

    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
    What came backWhat your code does
    abstain: false, answer: trueFlag it. Apply your policy, for example an alert or a control.
    abstain: false, answer: falseClear it. This is not proof the merchant is fine.
    abstain: true, or answer: nullSend it to a person with the reasons and the x-request-id. Never clear it.
    A 400 for one itemFix the field named in error.field. Do not clear the item.
    A 429, 502 or 503 for one itemRetry it. If it still fails, send it to review.
    The API is unreachableSend 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.

    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
    FileWhat it does
    plaid-sync-webhook.tsOn 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.tsA 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.tsReads 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.
    • 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.