A porcelain turnstile gate with a violet glass token in its slot

Enforce at the payment point

A payment adapter pays only with a Neltava capability it has just consumed for exactly that payment, fails closed, and reports what the rail says happened. How it works, with a reference implementation.

Neltava Team · Updated

Why a payment adapter

A decision is only as strong as the place that honours it. An agent that asks Neltava and follows the answer is governed while it cooperates; an agent that has been manipulated — or simply has another way to pay — is not. Enforcement has to live where money moves.

A payment adapter is that place: a small service you run in front of your payment rail, holding the only credentials that can pay. It pays only with a Neltava capability it has just consumed for exactly this payment. The agent never touches the rail.

The flow

enforce at the payment point
agent ──authorize──▶ Neltava ──capability──▶ agent
agent ──pay(capability, payment)──▶ payment adapter
adapter ──consume──▶ Neltava        refused ⇒ the rail is never called
adapter ──charge──▶ your rail       Stripe, a card issuer, a bank API, a wallet…
rail ──result / webhook──▶ adapter ──PAYMENT_RAIL outcome──▶ Neltava

In Enforce, every allowed spend — and every spend a person approved in review — carries a capability: a short-lived, single-use token signed by Neltava and bound to the workspace, the agent, the decision, the authority version, the payee, the amount ceiling, the currency and the action.

Rules it never breaks

  1. No charge without a consumed capability. Consumption is atomic: of two adapters racing to use one capability, exactly one succeeds. Any refusal means the rail is never called.
  2. Fail closed. If Neltava can’t be reached, nothing is charged.
  3. The rail tells the truth. What the rail reports is recorded as the outcome: a declined charge gives its budget back, an executed one counts at the amount actually charged, and a charge the rail never answered stays reserved until its webhook arrives. The agent’s own reports never release budget.

Set it up

  1. In the console, under Developers → API keys, create a Payment adapter key. It can consume capabilities and report rail outcomes for the payments it made — nothing else. It can’t ask for decisions, read other decisions or change anything.
  2. Give that key to the adapter only. The agent keeps its own key, which can only ask for decisions.
  3. Point the agent’s payment tool at the adapter — in the MCP case, a pay tool that requires the capability from authorize_spend.
  4. Remove every other way the agent can pay: no card numbers, wallet keys or billing credentials in its environment.
  5. Switch the agent to Enforce once its Shadow Mode readiness criteria are met.

Plugging in your rail

The adapter depends on one function, charge(). Everything rail-specific stays behind it: card-issuer APIs, payment intents, bank transfers, prepaid top-ups, a wallet’s signing service.

using the adapter
import { Neltava } from "neltava";
import { createPaymentAdapter, type PaymentRail } from "./adapter";

const rail: PaymentRail = {
  async charge({ idempotencyKey, merchant, payeeId, amountMinor, currency }) {
    // Call your rail here. Pass idempotencyKey (the decision id) through
    // if the rail supports one, so a retry can never charge twice.
    return { id: "…", status: "succeeded", amountMinor };
  },
};

const adapter = createPaymentAdapter({
  neltava: new Neltava({ apiKey: process.env.NELTAVA_ADAPTER_KEY }), // "Payment adapter" key
  rail,
});

// The agent's payment tool calls this — the only way it can pay:
const result = await adapter.pay({
  capability,                       // from the agent's decision
  merchant: "Clearbit",
  payeeId: "domain:clearbit.com",
  amountMinor: 4_900,
  currency: "USD",
});

// Your rail's webhook (verify its signature first):
await adapter.onRailEvent({ type: "refunded", decisionId, chargeId, amountMinor: 4_900, currency: "USD" });

Payee identity

When the agent asks for a decision, have it send the payee’s id at your rail — an account, a domain, an address — as payeeId. The capability then binds to that id, and the adapter must present the same one. A merchant name alone is matched after normalization; an id is exact.

What each refusal means

CodeMeaning
capability_usedThis decision's capability was already spent. One decision pays once.
capability_expiredThe capability's short window passed. The agent asks again.
capability_invalidNot a Neltava capability, tampered with, or from another workspace.
amount_exceeds_capabilityThe payment is larger than what was allowed (or capped).
payee_mismatchA different payee from the one the decision was for.
currency_mismatchA different currency.
action_mismatchA different kind of action — a subscription instead of a purchase.
authority_changedThe agent's authority changed after the decision; it must ask again under the new one.
agent_not_activeThe agent was paused or revoked.
decision_not_spendableThe decision doesn't authorize a payment: a BLOCK, an unreviewed REVIEW, or Shadow Mode.
neltava_unavailableNeltava couldn't be reached. Nothing was charged.

Every refusal is recorded in the workspace’s audit log, with what was presented — attempted substitutions leave a trace.

The reference implementation

The adapter below is the one our end-to-end tests run — against a real Neltava API and SDK, with only the rail replaced by a fake. It has no dependencies beyond the neltava SDK.

adapter.ts
import { Neltava, NeltavaError, NeltavaUnavailableError } from "neltava";

/**
 * A reference payment adapter — the one place money moves.
 *
 *   agent ──authorize──▶ Neltava ──capability──▶ agent
 *   agent ──pay(capability, payment)──▶ adapter
 *   adapter ──consume──▶ Neltava   (refused ⇒ no charge)
 *   adapter ──charge──▶ your rail  (Stripe, a card issuer, a bank API…)
 *   rail ──result / webhook──▶ adapter ──PAYMENT_RAIL outcome──▶ Neltava
 *
 * Rules it never breaks:
 *   1. No charge without a consumed capability for exactly this payment.
 *   2. If Neltava can't be reached, no charge (fail closed).
 *   3. What happened is reported from the rail, not from the agent — so a
 *      failed payment gives its budget back and an executed one counts at the
 *      amount actually charged.
 * It holds a `consume` key (a "Payment adapter" key in the console), never
 * the agent's key; the agent must have no other way to pay.
 */

export interface ChargeRequest {
  /** One charge per decision: rails that support idempotency keys dedupe retries. */
  idempotencyKey: string;
  merchant: string;
  payeeId?: string;
  amountMinor: number;
  currency: string;
  description?: string;
}

export interface ChargeResult {
  id: string;
  status: "succeeded" | "pending" | "failed";
  /** What the rail actually charged (may differ from what was asked). */
  amountMinor: number;
  failureReason?: string;
}

/** Your rail, behind one function. */
export interface PaymentRail {
  charge(req: ChargeRequest): Promise<ChargeResult>;
}

export interface PayRequest {
  capability: string;
  merchant: string;
  payeeId?: string;
  amountMinor: number;
  currency: string;
  actionType?: string;
  description?: string;
}

export type PayResult =
  | { ok: true; decisionId: string; charge: ChargeResult }
  | { ok: false; stage: "authority"; code: string; message: string }
  | { ok: false; stage: "rail"; decisionId: string; code: string; message: string; charge?: ChargeResult };

/** A later event from the rail about a charge (webhook). */
export interface RailEvent {
  type: "succeeded" | "failed" | "refunded";
  decisionId: string;
  chargeId: string;
  amountMinor: number;
  currency: string;
}

export function createPaymentAdapter(opts: { neltava: Neltava; rail: PaymentRail; now?: () => Date }) {
  const { neltava, rail } = opts;
  const now = opts.now ?? (() => new Date());

  const report = (decisionId: string, type: "SPEND_EXECUTED" | "SPEND_FAILED" | "REFUNDED", amountMinor: number, currency: string, ref: string) =>
    neltava.reportOutcome(decisionId, { type, amountMinor, currency, externalReference: ref, occurredAt: now(), source: "PAYMENT_RAIL" });

  async function pay(req: PayRequest): Promise<PayResult> {
    // 1. Neltava first. Anything but a consumed capability means: don't pay.
    let decisionId: string;
    try {
      const consumed = await neltava.consumeCapability({
        capability: req.capability,
        merchant: req.merchant,
        ...(req.payeeId ? { payeeId: req.payeeId } : {}),
        amountMinor: req.amountMinor,
        currency: req.currency,
        ...(req.actionType ? { actionType: req.actionType } : {}),
      });
      decisionId = String(consumed.decision_id);
    } catch (err) {
      if (err instanceof NeltavaError) return { ok: false, stage: "authority", code: err.code, message: err.message };
      if (err instanceof NeltavaUnavailableError) return { ok: false, stage: "authority", code: "neltava_unavailable", message: "Neltava could not be reached; nothing was charged" };
      return { ok: false, stage: "authority", code: "adapter_error", message: err instanceof Error ? err.message : String(err) };
    }

    // 2. The rail. An exception means we don't know whether money moved: report
    //    nothing — the reservation keeps counting until the rail tells us.
    let charge: ChargeResult;
    try {
      charge = await rail.charge({
        idempotencyKey: decisionId,
        merchant: req.merchant,
        ...(req.payeeId ? { payeeId: req.payeeId } : {}),
        amountMinor: req.amountMinor,
        currency: req.currency,
        ...(req.description ? { description: req.description } : {}),
      });
    } catch (err) {
      return { ok: false, stage: "rail", decisionId, code: "rail_unknown", message: `the rail did not answer (${err instanceof Error ? err.message : String(err)}); the spend stays reserved until the rail reports` };
    }

    // 3. What the rail says is what happened.
    if (charge.status === "succeeded") await report(decisionId, "SPEND_EXECUTED", charge.amountMinor, req.currency, charge.id);
    if (charge.status === "failed") {
      await report(decisionId, "SPEND_FAILED", req.amountMinor, req.currency, charge.id);
      return { ok: false, stage: "rail", decisionId, code: "rail_declined", message: charge.failureReason ?? "the rail declined the charge", charge };
    }
    return { ok: true, decisionId, charge };
  }

  /** Rail webhooks (verify the rail's signature before calling this). */
  async function onRailEvent(ev: RailEvent): Promise<void> {
    const type = ev.type === "succeeded" ? "SPEND_EXECUTED" : ev.type === "failed" ? "SPEND_FAILED" : "REFUNDED";
    await report(ev.decisionId, type, ev.amountMinor, ev.currency, ev.chargeId);
  }

  return { pay, onRailEvent };
}

Checklist

  • The adapter holds the only payment credentials; the agent holds none.
  • The adapter uses a Payment adapter key; the agent uses its agent key.
  • Decisions carry payeeId wherever your rail has a stable payee id.
  • Rail webhooks are signature-verified before they reach onRailEvent.
  • The rail call passes the decision id as its idempotency key.
  • An exception from the rail is treated as “unknown”, never as “failed”.

When you don’t need it

Start in Shadow Mode

See what your agents would have done — before anything is blocked.

Connect one agent through the SDK, MCP or one HTTP call. Shadow Mode is free: every spend is decided and recorded, nothing is blocked.

Start free in Shadow Mode →no card · one agent in minutes

Keep reading