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.
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_paymentand_authorizationand labels the proof with the resource's first payment method. - Forward caller headers only through the request. Set
backendHeaderson theCanonicalRequest. The HTTP backend lets a configured header, the derivedIdempotency-Keyand its ownContent-Typewin. It never forwardsAuthorization,Cookie,Set-Cookie,Host,Content-Lengthor hop-by-hop headers such asConnectionandProxy-*, and refuses an illegal name or value asINPUT_INVALIDbefore 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.
// '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 */ },
};
}verifymust not move funds;settleis the only place that does.- Distinguish rejected (bad payment, not retryable) from unavailable. If
verifyreaches no verdict, throwCommerceErrorwithPAYMENT_PROVIDER_UNAVAILABLE; untyped errors becomePAYMENT_INVALID. Asettleerror recordssettlement-uncertainand returns502 PAYMENT_SETTLEMENT_FAILED. When nothing was submitted, throwCommerceErrorwithdetails.settlementNotSubmitted: true. This marks the attemptrejected, releases its hold and returns a retryable503. - To settle after the backend, set
metadata.settleAfterBackend: trueon thePaymentRequirement, as the x402 and MPPauthorizationflow does. The pipeline then callssettleonly 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 withreclaimRejected, so retries can take over rejected attempts, including those rejected by the non-submissionCommerceErrordescribed above. healthmay listcomponents, each anameand astatus, 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
_payment stripped, shared envelopes where the wire contract permits, conformance fixtures, tests through a real client.docs/protocols/.See CONTRIBUTING.md for the development loop.