How AI is applied across API Evangelist and APIs.io. Read my AI disclosure →
API Evangelist API Evangelist
Discovery
Learnings
Guidance
Toolbox
Alignment
API Evangelist LLC

Payments And Banking: A Bundle For The Shape Of The Answer

November 6th, 2026 · Kin Lane
Payments And Banking: A Bundle For The Shape Of The Answer

This week I’ve been building bundles that pair a JSON Schema with OpenLint-format rules, after finding out that the decoder only pours part of the bedrock. When I looked at payments and banking, I said constrained output moves the remaining errors from formatting into meaning. Payments turns out to be the clearest case of what’s left once the format is solved, because so many of the fields have to agree with each other.

The answer I picked

Turn an approved invoice into a credit transfer instruction shaped like an ISO 20022 pain.001. Last time I couldn’t find a JSON Schema of the message that a decoder could use directly, and that hasn’t changed, so I had to make one. I want to be clear about what it is: the element names are ISO 20022’s, as they appear in published bank guides and in the contracts of banks like Citi, but the JSON binding is mine. I flattened some of the nesting to fit the page. You’d map this onto the real XML before it went anywhere, and a bank’s own implementation guide would have the last word.

bundle:
  id: payments-credit-transfer
  version: 0.1.0
  status: illustrative            # ISO 20022 element names in a JSON binding of ours, not a registered schema
  answers: Turn an approved invoice into a pain.001-style credit transfer instruction
  derived_from:
    - ISO 20022 pain.001 element names, as used in published bank contracts and guides
    - https://developer.gs.com/docs/services/transaction-banking/cbpr+-pain001/
engine:
  runtime: deepinfer
  tested_against: {xgrammar: 0.2.2, strict_mode: true}
  so_args: {schema_type: json}
  sampling: {temperature: 0, max_tokens: 2048, seq_seed: 20261106}
  env: {DEEPINFER_BATCH_INVARIANT: 1, DEEPINFER_ENABLE_MTP_VARLEN: 0}
input:
  context: [invoice, debtorName, debtorIban, currencies, today]
  rules:
    debtor-account-valid:
      description: Check our own account before spending any inference
      given: $.context.debtorIban
      severity: error
      then: {function: ibanMod97}
    invoice-attached:
      description: An instruction needs a source document
      given: $.context
      severity: error
      then: {field: invoice, function: truthy}
shape:
  type: object
  additionalProperties: false
  required: [GrpHdr, PmtInf]
  properties:
    GrpHdr:
      type: object
      additionalProperties: false
      required: [MsgId, NbOfTxs, CtrlSum, InitgPty]
      properties:
        MsgId: {type: string, minLength: 1, maxLength: 35}
        NbOfTxs: {type: string, pattern: "^[0-9]{1,15}$"}
        CtrlSum: {type: string, pattern: "^[0-9]{1,12}\\.[0-9]{2}$"}
        InitgPty:
          type: object
          additionalProperties: false
          required: [Nm]
          properties:
            Nm: {const: "${context.debtorName}"}
    PmtInf:
      type: object
      additionalProperties: false
      required: [ReqdExctnDt, DbtrAcct, CdtTrfTxInf]
      properties:
        ReqdExctnDt: {type: string, format: date}
        DbtrAcct: {const: "${context.debtorIban}"}
        CdtTrfTxInf:
          type: array
          minItems: 1
          items:
            type: object
            additionalProperties: false
            required: [EndToEndId, InstdAmt, Ccy, CdtrNm, CdtrIBAN]
            properties:
              EndToEndId: {type: string, minLength: 1, maxLength: 35}
              InstdAmt: {type: string, pattern: "^[0-9]{1,12}\\.[0-9]{2}$"}
              Ccy: {enum: "${context.currencies}"}
              CdtrNm: {type: string, minLength: 1, maxLength: 140}
              CdtrIBAN: {type: string, pattern: "^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$"}
output:
  rules:
    control-sum-matches:
      description: CtrlSum must equal the sum of the instructed amounts
      given: $
      severity: error
      then:
        function: sumEquals
        functionOptions: {items: answer.PmtInf.CdtTrfTxInf, field: InstdAmt, total: answer.GrpHdr.CtrlSum}
    transaction-count-matches:
      description: NbOfTxs must equal the number of transactions
      given: $
      severity: error
      then:
        function: countEquals
        functionOptions: {items: answer.PmtInf.CdtTrfTxInf, count: answer.GrpHdr.NbOfTxs}
    creditor-iban-checks-out:
      description: The pattern checks the form, this checks the check digits
      given: $.answer.PmtInf.CdtTrfTxInf[*].CdtrIBAN
      severity: error
      then: {function: ibanMod97}
    no-duplicate-transactions:
      description: The decoder does not enforce uniqueItems, so this rule catches exact repeats
      given: $.answer.PmtInf
      severity: error
      then:
        function: schema
        functionOptions:
          schema:
            type: object
            properties:
              CdtTrfTxInf: {type: array, uniqueItems: true}
    not-in-the-past:
      description: A payment cannot be executed before today
      given: $
      severity: error
      then:
        function: notAfter
        functionOptions: {earlier: context.today, later: answer.PmtInf.ReqdExctnDt}
on_failure:
  error: {retry: 1, change: [seq_seed, append_rule_messages_to_prompt], then: send_to_human}
  always: {action: human_releases_payment}

Where the design choices are

Money is a string. InstdAmt is "1250.00", enforced by a pattern, not a JSON number. A decoder happily emits 1250.1000000000001 if you let it, and floating point has no business near a payment. It also means my functions add in integer minor units instead of summing floats. Note that this two-decimal pattern is itself a simplification, because not every currency has two decimal places, and a real bundle would have to tie the pattern to the currency.

The grammar can’t do arithmetic. This is the heart of it. NbOfTxs has to equal the number of transactions and CtrlSum has to equal the sum of the amounts, and guides warn that a bank may reject the file when they don’t. These are the exact facts a token-at-a-time grammar can’t hold. They can only live in rules.

A pattern checks the form, and the check digits need a function. The IBAN pattern lets through anything IBAN-shaped. creditor-iban-checks-out runs the actual mod-97 check. A model transposing two digits while copying an account number from a PDF is exactly the mistake I’d worry about, and the decoder can’t see it.

uniqueItems is here on purpose. I said the decoder doesn’t enforce it, and my shape lint would fail if it appeared in the shape. So it moves to no-duplicate-transactions, where the linter’s schema function enforces it after generation. This is what it looks like when a keyword moves out of the bedrock and into the landscaping. It only catches exact repeats, though. Two transactions sharing an EndToEndId with different amounts would pass, and a real bundle would need a function comparing just that field.

The debtor is bound. Who’s paying isn’t a question for a model. The debtor’s name and account come in as const values, and the currencies this business has approved become the enum.

How it runs as part of inference

  1. Input rules run first, including the mod-97 check on our own debtor account. It’s cheap to find out before spending anything on inference that the account on file is wrong.
  2. The shape is bound and compiled, in 144 milliseconds on my laptop against DeepSeek’s real tokenizer.
  3. The decoder generates. It can’t emit an unapproved currency or a number where a string belongs.
  4. Output rules run on the envelope, comparing the amounts, counting the transactions, and checking the dates against today.
  5. On failure, it retries once, then goes to a person. Fewer retries than the other bundles, on purpose. A model that gets the arithmetic wrong twice on a payment shouldn’t get a third try.
  6. A person releases the payment. No bundle changes that.

What the fixtures showed

These are hand-written documents testing each path, not model output.

  • A clean single-line instruction passed everything.
  • A broken one was accepted by the decoder: two transactions under a header that says one, a control sum for one, an exact duplicate transaction, a creditor IBAN with one wrong digit, and an execution date in the past. The rules flagged all five.
  • A Bitcoin ticker in the currency field, and a bare number in CtrlSum, were refused by the decoder.
  • A debtor account failing its own check digits and a missing invoice were stopped by the input rules.

The second result is the one worth sitting with. It’s a document the decoder considers valid, and a bank’s systems would bounce it, or worse, not bounce all of it. One wrong IBAN digit is usually caught by the check. A correct IBAN that belongs to someone else isn’t caught by anything here.

What’s still missing

That last sentence is the real limit. These rules prove that the instruction is internally consistent. They don’t prove the creditor is who the invoice says, that the invoice is genuine, or that it should be paid. A supplier who changes their bank details by email is a fraud pattern, and a model extracting those new details perfectly and consistently makes it worse. Checks like confirmation of payee, the vendor master and sanctions screening sit outside of this file and need to stay there.

My first post also described a dispute shape and a remittance one, and I left both out. Disputes would need the card network reason codes I couldn’t source publicly last time, and I still won’t guess at them. Remittance matching would need the bank statement as context, which is a much larger envelope. Also, ReqdExctnDt being today or later says nothing about cutoff times or bank holidays, and those are the checks a real payment file fails on.

Payments has had an agreed shape for decades. What a bundle adds is a place to write down the relationships inside that shape, the ones that have always been in bank guides as prose. Those were going to be checked by somebody anyway. A model in the loop just means they have to be checked by something that doesn’t get tired.