DEV Community

Cover image for Your Error Messages Are an API
Tushar Shukla
Tushar Shukla

Posted on

Your Error Messages Are an API

Does connector X support Apple Pay refunds?

It sounds like a question with a one-word answer. It is not. Prism — a payments connector library written in Rust — integrates close to a hundred payment processors. Each one supports some set of flows (authorize, capture, refund, void, sync, disputes, payouts, mandates…) and, within each flow, some set of payment methods (raw card, Apple Pay, Google Pay, iDEAL, UPI, SEPA, Pix, Klarna, and roughly ninety more). Multiply it out and "which connector does what" is a grid with tens of thousands of cells.

Somebody has to fill that grid in. It's the first thing a merchant asks before they integrate, the thing that decides whether a routing rule is even possible, and the thing that goes stale the instant an engineer adds a flow to a connector and forgets to update a doc.

This post is about how Prism fills that grid in — for every connector × flow × payment-method combination, with zero network calls, no sandbox credentials, and no human maintaining a spreadsheet.

The trick: treat every connector's own error messages as a queryable API.


Two ways to build the matrix, both bad

There are two obvious ways to find out what a connector supports, and both are traps.

Option 1: call every sandbox. Spin up a test account with all ~100 processors, fire a real authorize/refund/etc. for every payment method, and record what comes back. This is how a lot of teams do "synthetic transactions," and for liveness monitoring it's the right tool. But as a way to build a capability matrix it's miserable: you need a hundred sets of live credentials, half the sandboxes are flaky or rate-limited, some payment methods can't be exercised without a real consumer device, it costs money, and a full sweep takes hours you can't put in CI. Worst of all, a network failure looks exactly like an unsupported feature.

Option 2: maintain the matrix by hand. Write it down in a Markdown table and keep it updated. This works for exactly as long as it takes the second engineer to add a flow without touching the docs. A hand-maintained capability matrix isn't a source of truth; it's a source of confidently-wrong answers with a timestamp.

Both options share the same flaw: they treat the connector's capabilities as something external to be measured, when the truth is already sitting in the code.


The reframe: a transformer is a pure function

Here's the thing every Prism connector already has. Before a payment ever hits the network, the connector runs a request transformer: a function that takes Prism's unified request and turns it into the specific processor's wire format. Give it a valid request and it hands you back an HTTP Request. Give it something it can't handle and it hands you back a typed error.

In Rust terms, its signature is essentially:

fn(Request) -> Result<Option<common_utils::request::Request>, IntegrationError>
Enter fullscreen mode Exit fullscreen mode

That signature is the whole opportunity. The transformer is (near enough) a pure function: no I/O, no network, deterministic. So instead of asking a sandbox "do you support this?", you can ask the transformer — in-process, in microseconds, for free. And you can ask it about every combination without a single packet leaving the machine.

That's what the field-probe crate does (crates/internal/field-probe/, ~5,200 lines across 15 source files). Its whole strategy fits in the module doc:

//! crates/internal/field-probe/src/main.rs
//! For each (connector, flow, pm_type):
//!   1. Build a maximally-populated proto request with all standard fields set.
//!   2. Call the ffi req_transformer directly (no HTTP).
//!   3. Ok(Some(req))  → supported; record (url, method, headers, body).
//!   4. Ok(None)       → connector skips this flow/pm (returns None intentionally).
//!   5. Err(e)         → parse error, patch proto request, retry up to MAX_ITERS.
Enter fullscreen mode Exit fullscreen mode

And critically, it drives the real transformer — the exact same connector crate the production SDKs are built on:

// crates/internal/field-probe/src/main.rs
extern crate connector_service_ffi as ffi;
Enter fullscreen mode Exit fullscreen mode

No mocks. No stubs. The thing being probed is the exact code that runs in production. It just never gets as far as the network.

field-probe run: stripe / authorize / apple_pay — missing field, patch, retry, supported. 97 connectors written, 0 packets sent


Four outcomes, one enum

When you call a transformer, exactly four things can happen, and field-probe names them:

// crates/internal/field-probe/src/status.rs
pub enum FlowStatus {
    Supported,       // "supported"       — the connector produced a real HTTP request
    NotImplemented,  // "not_implemented" — Ok(None), or an empty default impl (no URL)
    NotSupported,    // "not_supported"   — the connector explicitly rejects this method
    Failed,          // "error"           — a required field we couldn't fill in
}
Enter fullscreen mode Exit fullscreen mode

Deciding between the first two is delightfully literal:

// crates/internal/field-probe/src/probe_engine.rs
match call(req.clone()) {
    Ok(Some(connector_req)) => {
        // An empty URL means the connector fell through to a default trait impl
        // that never set one — i.e. this flow isn't really wired up.
        if connector_req.url.is_empty() {
            ProbeAttemptResult::NotImplemented
        } else {
            ProbeAttemptResult::Success(connector_req)
        }
    }
    Ok(None) => ProbeAttemptResult::NotImplemented,
    Err(e)   => ProbeAttemptResult::Error(e.error_message),
}
Enter fullscreen mode Exit fullscreen mode

That empty-URL check is a nice tell. In Prism, a connector that doesn't support a flow doesn't crash and doesn't have a missing method — it inherits a typed default implementation that returns an empty request. field-probe reads that empty URL as "not wired up," which is exactly what it is. The type system's fallback becomes the prober's signal.

The interesting case is the fourth outcome — the error — because that's where the connector tells you why it said no.


The oracle: the error message is the API

When the transformer returns an error, field-probe doesn't just record "failed." It reads the message and classifies it against a set of pattern tables:

// crates/internal/field-probe/src/error_parsing.rs
const NOT_IMPLEMENTED_PATTERNS: &[&str] =
    &["not been implemented", "notimplemented", "not implemented"];

const NOT_SUPPORTED_PATTERNS: &[&str] = &[
    "not supported", "unsupported", "only card payment", "only upi",
    "payment method not supported", "does not support this payment", /* … */
];

const MISSING_FIELD_PATTERNS: &[&str] = &[
    "Missing required param: ", "Missing required field: ",
    "MissingRequiredField { field_name: \"", "field_name: \"",
];
Enter fullscreen mode Exit fullscreen mode

Three buckets, three meanings:

  • "not implemented" → the connector code exists but this flow isn't finished. Stop. Record not_implemented (⚠).
  • "not supported" → the connector deliberately rejects this payment method. Stop. Record not_supported (x).
  • "missing required field: billing_address" → this is the one that's actionable. The connector is telling you precisely what it needs. So give it that, and ask again.

That last bucket is the whole idea in miniature. The error message isn't a dead end — it's a machine-readable spec of the connector's requirements, emitted one field at a time.


The self-healing loop

Put those pieces together and you get a fixpoint loop: call the transformer, read the complaint, fix the complaint, call again — until the connector stops complaining (supported) or complains about something you can't fix (stuck).

The field-probe engine draws its own diagram in the source, reproduced here:

// crates/internal/field-probe/src/probe_engine.rs
┌──────────────┐     ┌──────────────────┐     ┌─────────────┐
│ Base Request │────▶│ Connector        │────▶│ Success?    │
│ (minimal)    │     │ Transformer      │     └──────┬──────┘
└──────────────┘     └──────────────────┘            │
                           ┌──────────Yes────────────┘
                           ▼
                    ┌──────────────┐
                    │ Mark as      │
                    │ "supported"  │
                    └──────────────┘
                           │ No
                           ▼
                    ┌──────────────┐
                    │ Classify     │
                    │ Error        │
                    └──────┬───────┘
             ┌─────────────┼─────────────┐
             ▼             ▼             ▼
      ┌──────────┐  ┌──────────┐  ┌──────────┐
      │ NotImpl  │  │ NotSupp  │  │ Missing  │
      │ → Stop   │  │ → Stop   │  │ → Patch  │
      └──────────┘  └──────────┘  └────┬─────┘
                                       │
                                       ▼
                             ┌──────────────────┐
                             │ Retry with       │
                             │ patched request  │
                             └──────────────────┘
Enter fullscreen mode Exit fullscreen mode

In code, the loop is small enough to read in one sitting:

// crates/internal/field-probe/src/probe_engine.rs
for _iteration in 0..max_iterations() {           // max_iterations = 30, from probe-config.toml
    match attempt_probe(flow_name, &req, &mut call) {
        ProbeAttemptResult::Success(connector_req) => return handle_success(...),
        ProbeAttemptResult::NotImplemented         => return handle_not_implemented(...),
        ProbeAttemptResult::Error(msg) => match classify_error_action(&msg) {
            ErrorAction::Stop(status)         => return handle_error_status(status, ...),
            ErrorAction::PatchAndRetry(field) => {
                if !handle_patch_attempt(&field, &mut seen_fields, &mut required_fields,
                                         &mut patch, &mut req) {
                    return handle_stuck_field(&field, &msg, required_fields);
                }
            }
        },
    }
}
Enter fullscreen mode Exit fullscreen mode

Every time the loop successfully patches a field, that field is appended to required_fields. So when the loop terminates in supported, it hasn't just answered "is this supported?" — it has, as a side effect, reverse-engineered the connector's exact list of required fields for that flow and payment method, purely from the connector's own error messages. The support matrix and the field-level docs fall out of the same loop.

Why the loop always terminates

A retry loop driven by error messages has an obvious failure mode: what if patching field A makes the connector ask for field A again? You'd spin forever. field-probe guards against it with a seen_fields set:

// crates/internal/field-probe/src/probe_engine.rs
fn handle_patch_attempt<Req>(field: &str, seen_fields: &mut HashSet<String>, ...) -> bool {
    if seen_fields.contains(field) {
        return false;                 // we already patched this once → we're stuck
    }
    seen_fields.insert(field.to_string());
    required_fields.push(field.to_string());
    patch(req, field);
    true
}
Enter fullscreen mode Exit fullscreen mode

If a field is requested a second time, handle_patch_attempt returns false, the loop bails out to handle_stuck_field, and the result is recorded as an honest error with the message "Stuck on field: …". Between the seen_fields guard and the hard cap of 30 iterations, the fixpoint is guaranteed to converge. It either finds a request the connector accepts, or it tells you precisely which field defeated it.


The base request has to be incomplete

There's a subtle design decision here that's easy to get backwards, and it's my favorite detail in the whole crate.

For the loop to discover what a connector requires, the starting request must not already contain everything. If you pre-populate the base request with a full billing address and a full customer object, then a connector that requires billing address will happily succeed — and you'll never learn that it was required. The requirement becomes invisible.

So field-probe keeps the rich patch values — a complete Address, a complete Customer — quarantined in the patcher, deliberately out of reach of the code that builds base requests:

// crates/internal/field-probe/src/patcher.rs
// These build full proto structs used as patch values (e.g. when a connector
// reports "billing_address is missing" and we need the whole Address object).
// They live here — not in sample_data — so requests.rs cannot import them and
// accidentally pre-populate base requests, which would hide required-field
// discovery.
fn full_address() -> grpc_api_types::payments::Address { /* John Doe, 123 Main St, Seattle … */ }
Enter fullscreen mode Exit fullscreen mode

The tool's correctness depends on its inputs being minimal. That's the fine print behind "maximally-populated with all standard fields" from the strategy comment earlier: standard means the scalars every payment carries — amount, currency, a card number — and it pointedly excludes the heavy nested objects. Those are withheld on purpose, so that whether a connector needs them is something the connector tells you, not something you assumed. When the "missing field" error arrives, smart_patch resolves the reported field name to the right typed value (driven by rules in patch-config.toml) and splices it in via a JSON round-trip. Then the loop asks again.


Honest caveats

This is discovery, not omniscience. It's worth being precise about what a zero-network prober can and can't know.

It can't invent values that only exist at runtime. The clearest example lives in the real probe output for Cashfree:

// data/field_probe/cashfree.json
"authorize": {
  "Ach": {
    "status": "error",
    "error": "Stuck on field: payment_session_id. Cashfree V3 requires a
              payment_session_id from the CreateOrder response to authorize a payment"
  }
}
Enter fullscreen mode Exit fullscreen mode

Cashfree's authorize needs a payment_session_id — but that value is minted by a previous CreateOrder call. There is no static value the prober can splice in; the field is fundamentally an output of another flow. So the loop hits the seen_fields guard and stops. And this is the right behavior: it reports ? (error / needs a field it can't supply), not a false . A prober that guessed here would be worse than useless. Reporting "I couldn't complete this, and here's exactly why" is the honest answer.

It verifies construction, not acceptance. A supported result means the connector built a well-formed request for that combination. It does not mean the processor's sandbox would approve it — no packet was sent. Construction is a strong signal (it's the part Prism owns and the part that breaks when a connector changes), but it is not an end-to-end guarantee. This is the deliberate trade: you give up "the gateway said yes" in exchange for exhaustive, deterministic, free, flake-free coverage of every combination.

The oracle is heuristic. Classification relies on matching error strings. A connector that phrases "not supported" in some novel way needs a new pattern in the table. That's a real maintenance cost — though a cheap and centralized one, and a failing classification shows up as a conspicuous error, not a silent wrong answer.

A note on synthetic transactions: the classic observability advice is to run real test transactions through production against live sandboxes to exercise rare paths. That's excellent for liveness — is the pipeline up right now? field-probe answers a different question — what is each connector even capable of? — and answers it for every path at build time, in-process, with the transformer's own errors as the oracle. No money, no network, no flakiness. The two are complements, not substitutes.


Built to stay in sync, and to run fast

Two more properties make this practical at ~100-connector scale.

It can't drift from the code. The per-flow probe runners aren't hand-written — they're generated at build time from the FFI surface itself:

// crates/internal/field-probe/src/flow_registry.rs
// Flows are automatically discovered from FFI at build time.
include!(concat!(env!("OUT_DIR"), "/flow_runners_generated.rs"));
Enter fullscreen mode Exit fullscreen mode

Add a new flow to the library and the prober picks it up on the next build. The tool that documents the connectors is regenerated from the same source the connectors are.

It's embarrassingly parallel. Every connector is independent, so the whole sweep is a rayon par_iter:

// crates/internal/field-probe/src/main.rs
let results: Vec<_> = connectors
    .par_iter()
    .map(|c| probe_connector(c))
    .collect();
Enter fullscreen mode Exit fullscreen mode

No network means no rate limits and no waiting — the sweep is CPU-bound, so it finishes in the time it takes to serialize a few thousand structs across your cores.


The payoff: a matrix that rewrites itself

Each connector's probe results are written to data/field_probe/{connector}.json — 97 files today, one per connector. Those files are the single input to the documentation generator:

# scripts/generators/docs/generate.py
#   1. Loads probe data from data/field_probe/{connector}.json
#   2. All content is derived exclusively from probe data — no manual annotation files
#   3. Outputs docs-generated/connectors/{name}.md
Enter fullscreen mode Exit fullscreen mode

"Derived exclusively from probe data — no manual annotation files." That line is the entire point. The public capability matrix (docs-generated/all_connector.md) is not maintained; it is computed. Its legend maps one-to-one onto the FlowStatus enum we started with:

Legend:  ✓ Supported   x Not Supported   ⚠ Not Implemented   ? Error / Missing required fields
Enter fullscreen mode Exit fullscreen mode

The support matrix that writes itself — every ✓ and ✗ across ~100 connectors, derived exclusively from probe data

Run make docs, and every ✓, x, ⚠, and ? in that grid traces back to a real transformer being driven by a real request and answering for itself. The matrix can't lie about the code, because the matrix is the code, interrogated.


This isn't unique to payments

Strip away the connectors and the pattern is general: if your validation errors are structured enough to act on, your own validation code is a discovery tool. A few principles carry over to any system with a large surface of "does this thing support that thing?":

  1. Drive the real function, not a description of it. Documentation drifts; a pure function interrogated in-process cannot. If the behavior you want to document is already a deterministic function, call it — don't transcribe it.
  2. Make errors machine-readable, then let them drive a loop. A "missing field: X" that names X isn't just a message to a human; it's one step of a fixpoint. Error-classification-as-introspection is the close cousin of parse-don't-validate.
  3. Keep the probe's inputs minimal on purpose. Discovery only works when the thing you're measuring is free to reveal what it needs. Over-provision the input and you hide the very requirement you were trying to find.

You don't need Rust for any of this. You need pure-ish functions and errors that say something. But a strict type system that turns "unsupported flow" into a typed default and "missing field" into a structured error makes the whole approach fall out almost for free.


Closing

The most reliable documentation is the kind nobody writes. A hand-maintained support matrix is a promise to keep doing work forever, and it's a promise every team eventually breaks. field-probe makes a different bet: that the connectors already know what they support, that they say so every time they refuse a request, and that a small loop reading those refusals can build a truer matrix than any human would keep current.

It's a slightly strange idea — fuzzing your own code and treating its complaints as an API. But it turns "is this still accurate?" from a recurring chore into a build step. The grid fills itself in, and it's right because it can't be anything else.


Prism is open source — built in Rust, with SDKs across languages.

GitHub logo juspay / hyperswitch-prism

One library | Many payment processors | Scale to multiple processors with few lines of code.

Hyperswitch Prism

One integration. Any payment processor. Switch processors with few lines of code.

Switch processors with few lines of code

License: Apache 2.0

Website · Documentation · Slack Community

What is Prism?

Prism is a stateless, unified connector library to connect with any payment processor. It is extracted out of the hardened integrations through continuous testing & iterative bug fixing over years of usage within Juspay Hyperswitch.

Why are payment processor integrations such a big deal?

Every payment processor has diverse APIs, error codes, authentication methods, pdf documents to read, and behavioural differences between the actual environment and documented specs.

A small mistake or oversight can create a huge financial impact for businesses accepting payments. Thousands of enterprises around the world have gone through this learning curve and iterated and fixed payment systems over many years. All such fixes/improvements/iterations are locked-in as tribal knowledge into Enterprise Payment Platforms and SaaS Payment Orchestration solutions.

Hence, Prism - to open…





If this was useful, a ⭐ on GitHub goes a long way. And if you've used your own error messages as an API — to build docs, a capability matrix, or a config validator — I'd love to hear how in the comments.

Top comments (0)