Extend

Write an adapter

Add a protocol or a payment rail without touching the core — and the rules every adapter follows.

This page covers two of the three extension points (the third is an authorization provider, as AP2 implements), and none should require changing src/core. If yours does, raise it as a contract change before writing code. Protocol and rail names are closed unions in the frozen contract: a new implementation of an existing name needs no core change, but a new name needs an approved contract update first. Once approved, a new name also gets a strict config block in src/config/schema/raw.ts, its normalization in a module under src/config/schema/, and its own page under docs/protocols/; see contributing-adapters.md.

A protocol adapter

Implement ProtocolAdapter, or HttpProtocolAdapter for a protocol served over HTTP. You get one thing to work with: a ProtocolAdapterContext — the pipeline, the resource registry, an event sink, a logger, a clock, an id generator and the public base URL.

example-adapter.tstypescript
import type { HttpProtocolAdapter, ProtocolAdapterContext } from '@devlab.group/agent-commerce';
import { toCommerceError, toErrorEnvelope, toPaymentRequiredEnvelope } from '@devlab.group/agent-commerce';

// 'example' type-checks only after it is added to ProtocolName,
// which is a frozen-contract change
export function createExampleAdapter(): HttpProtocolAdapter {
  let ctx: ProtocolAdapterContext | undefined;

  return {
    name: 'example',
    mountPath: '/example',
    descriptor: {
      name: 'example',
      kind: 'protocol',
      implementationVersion: '1.0.0',
      supportedSpec: 'example-spec@2026-01-01',   // pin it
      capabilities: ['discovery', 'invoke'],
      unsupported: ['subscriptions', 'batch'],    // be explicit
      status: 'experimental',
    },

    async start(context) {
      ctx = context;
      for (const resource of context.resources.listExposedVia('example')) {
        // map resource.inputSchema into your protocol's schema language
      }
    },

    async handleHttp(req, res) {
      if (!ctx) throw new Error('adapter not started');
      try {
        const outcome = await ctx.pipeline.execute({
          requestId: ctx.ids.next('req'),
          resourceId: /* from the wire */ '',
          input: /* from the wire, payment field removed */ {},
          protocol: 'example',
          receivedAt: ctx.clock.nowIso(),
        });
        if (outcome.kind === 'payment-required') {
          return respond(res, 402, toPaymentRequiredEnvelope(outcome));
        }
        respond(res, 200, outcome.body);
      } catch (error) {
        const commerceError = toCommerceError(error);
        respond(res, commerceError.httpStatus, toErrorEnvelope(commerceError));
      }
    },

    async health() {
      return { status: ctx ? 'pass' : 'fail', checkedAt: new Date().toISOString() };
    },

    async stop() {
      ctx = undefined;
    },
  };
}

Rules

  • Never call a merchant backend. Everything goes through pipeline.execute; bypassing it bypasses payment enforcement.
  • Never implement payment logic. Surface the payment-required outcome; do not decide what is owed.
  • Strip the reserved fields with extractReservedInputFields(rawInput, resource, requestId) when the protocol carries input as an object: it removes and parses _payment and _authorization and labels the proof with the resource's first payment method.
  • Forward caller headers only through the request. Set backendHeaders on the CanonicalRequest. The HTTP backend lets a configured header, the derived Idempotency-Key and its own Content-Type win. It never forwards Authorization, Cookie, Set-Cookie, Host, Content-Length or hop-by-hop headers such as Connection and Proxy-*, and refuses an illegal name or value as INPUT_INVALID before pricing.
  • Map errors deterministically with toErrorEnvelope(toCommerceError(e)). No stack traces on the wire.
  • Fail in isolation, and be honest in the descriptor.

A payment provider

Implement PaymentProvider: createRequirement, verify, settle and health. A rail with several offers can also implement the optional selectRequirement, which picks the proof's offer before verify. An optional experimental reconcile method supplies chain evidence to agent-commerce reconcile. Providers without it need resolve for uncertain attempts.

typescript
// 'example-rail' type-checks only after it is added to PaymentMethodName
export function createExampleProvider(options: ExampleOptions): PaymentProvider {
  return {
    name: 'example-rail',
    descriptor: { /* honest */ },

    async createRequirement(context) {
      return {
        id: /* … */,
        requestId: context.requestId,
        resourceId: context.resource.id,
        provider: 'example-rail',
        amount: context.amount,          // decimal display units, unchanged
        currency: context.currency,
        destination: options.payTo,      // merchant-controlled; omit it when offering several
        challenge: { provider: 'example-rail', version: '1', accepts: [/* native, opaque */] },
      };
    },

    // Optional: return the matched offer's destination, network, asset and
    // metadata, keeping id and challenge. Never verify, reserve or settle here
    async selectRequirement(context) { /* … */ },

    async verify(context) {
      // No side effects that move money. Ever.
      // Return { status: 'rejected', rejectionReason } for a bad proof.
      // MUST return a replayKey derived ONLY from the authorization.
    },

    async settle(context) {
      // Runs only after verify succeeded AND the replay key was reserved.
    },

    async health() { /* fast, never throws */ },
  };
}
  • verify must not move funds; settle is the only place that does.
  • Distinguish rejected (bad payment, not retryable) from unavailable. If verify reaches no verdict, throw CommerceError with PAYMENT_PROVIDER_UNAVAILABLE; untyped errors become PAYMENT_INVALID. A settle error records settlement-uncertain and returns 502 PAYMENT_SETTLEMENT_FAILED. When nothing was submitted, throw CommerceError with details.settlementNotSubmitted: true. This marks the attempt rejected, releases its hold and returns a retryable 503.
  • To settle after the backend, set metadata.settleAfterBackend: true on the PaymentRequirement, as the x402 and MPP authorization flow does. The pipeline then calls settle only after a successful backend call, and withholds the response if settlement is refused or unconfirmed.
  • If an authorization stays reusable until settlement, set metadata.reuseRejectedAuthorization: true, as x402 does. The pipeline reserves with reclaimRejected, so retries can take over rejected attempts, including those rejected by the non-submission CommerceError described above.
  • health may list components, each a name and a status, such as one per offer. Discovery may publish them, so keep diagnostic detail out.
  • The gateway must never need a merchant or buyer private key to use your rail.

Before review

Protocol adapter checklistSchema mapping, request normalization with _payment stripped, shared envelopes where the wire contract permits, conformance fixtures, tests through a real client.
Payment provider checklistNegative tests for every failure, a real settlement proof, no buyer or merchant key, an honest descriptor, and the rail's page under docs/protocols/.

See CONTRIBUTING.md for the development loop.