Extend

Embed the gateway

Compose the gateway in your own Node process with createGateway, providers and adapters.

createGateway is dependency-injected: you hand it a validated config, a receipt store, payment providers, optional authorization providers and protocol adapters. This is the same composition the gateway's own entry point performs.

Full composition

gateway.tstypescript
import {
  createA2aAdapter,
  createAcpAdapter,
  createGateway,
  loadConfig,
  receipts,
  type PaymentProvider,
  type ProtocolAdapter,
} from '@devlab.group/agent-commerce';
import { mcp } from '@devlab.group/agent-commerce/mcp';
import { x402 } from '@devlab.group/agent-commerce/x402';
import { mpp } from '@devlab.group/agent-commerce/mpp';
import { ap2 } from '@devlab.group/agent-commerce/ap2';

const config = await loadConfig();            // AGENT_COMMERCE_CONFIG or ./config.yaml

const store = receipts({ path: config.storage.receipts.path });
await store.init();                           // a store that will not open is fatal

const paymentProviders: PaymentProvider[] = [];
const rail = config.payments.x402;
if (rail?.enabled) {
  paymentProviders.push(
    x402({
      offers: rail.options ?? [rail],         // every offer, in challenge order
      ...(rail.resourceOptions !== undefined
        ? { resourceOffers: rail.resourceOptions }   // resources with their own x402.accepts
        : {}),
      ...(rail.networks !== undefined ? { customNetworks: rail.networks } : {}),
      maxTimeoutSeconds: rail.maxTimeoutSeconds,
      ...(rail.paymentFlow !== undefined ? { paymentFlow: rail.paymentFlow } : {}),
      ...(rail.resourcePaymentFlows !== undefined
        ? { resourcePaymentFlows: rail.resourcePaymentFlows }
        : {}),
      ...(rail.resourceMimeTypes !== undefined
        ? { resourceMimeTypes: rail.resourceMimeTypes }   // each resource's mimeType
        : {}),
      facilitator: rail.facilitator,
      ...(rail.allowMainnet !== undefined ? { allowMainnet: rail.allowMainnet } : {}),
      ...(rail.allowUnauthenticatedFacilitator !== undefined
        ? { allowUnauthenticatedFacilitator: rail.allowUnauthenticatedFacilitator }
        : {}),
      ...(rail.quote !== undefined ? { quote: rail.quote } : {}),   // ERC-8409 quotes
    }),
  );
}
const charge = config.payments.mpp;
if (charge?.enabled) {
  paymentProviders.push(
    mpp({
      recipient: charge.recipient as `0x${string}`,
      asset: charge.asset as `0x${string}`,
      assetName: charge.assetName,
      assetVersion: charge.assetVersion,
      network: charge.network,
      realm: charge.realm,
      challengeSecret: charge.challengeSecret,
      rpcUrl: charge.rpcUrl,
      facilitator: charge.facilitator,
      ...(charge.paymentFlow !== undefined ? { paymentFlow: charge.paymentFlow } : {}),
      ...(charge.resourcePaymentFlows !== undefined
        ? { resourcePaymentFlows: charge.resourcePaymentFlows }
        : {}),
      // plus challengeTtlSeconds and the two mainnet acknowledgments, when set
    }),
  );
}

const mandates = config.authorization?.ap2;
// AP2 holds mandates in the receipt store
const authorizationProviders = mandates?.enabled ? [ap2({ config: mandates, store })] : [];

const protocolAdapters: ProtocolAdapter[] = [];
if (config.protocols.mcp.enabled) {
  protocolAdapters.push(mcp({ mountPath: config.protocols.mcp.mountPath }));
}
if (config.protocols.a2a.enabled) {
  protocolAdapters.push(
    createA2aAdapter({
      mountPath: config.protocols.a2a.mountPath,
      agentName: config.merchant.name,   // the Agent Card names the merchant
      store,                             // unpaid x402 tasks survive a restart
    }),
  );
}
const acp = config.protocols.acp;
if (acp.enabled) {
  protocolAdapters.push(
    createAcpAdapter({
      mountPath: acp.mountPath,
      token: acp.auth.token,
      operations: acp.checkout.operations,
      idempotency: acp.idempotency,
      store,                             // claims live in the receipt store
      ...(acp.discovery !== undefined ? { discovery: acp.discovery } : {}),
    }),
  );
}

const gateway = await createGateway({
  config,
  store,
  paymentProviders,
  authorizationProviders,
  protocolAdapters,
});

const { url } = await gateway.listen();

process.on('SIGTERM', async () => {
  await gateway.close();                      // drains requests, then stops adapters
  for (const provider of authorizationProviders) provider.close();
  await store.close();
  process.exit(0);
});
Whoever creates an AP2 provider must call its close(): gateway.close() does not close authorization providers. AP2, ACP and A2A leave the shared store open; close it last. Without one, ACP needs idempotency.path, AP2 needs replay.path (both deprecated), and A2A stores unpaid tasks in memory.
Upgrading a 2.5 compositionCreating receipts() migrates older files to schema v5, which 2.5 cannot open. To import 2.5 AP2/ACP files, pass legacyStores: { ap2Replay, acpIdempotency, enabled: { ap2, acp } } (experimental). init() imports each file once and holds its write lock until close(). CLI startup derives these options from the deprecated keys.
Pass the offers and flows throughThe x402 factory defaults to the authorization flow and MPP's to upfront. A composition that omits paymentFlow and resourcePaymentFlows ignores the flow set in config.yaml. Likewise, pass every offer: config.payments.x402.options lists them all, while the rail's own fields mirror only the first. Without resourceOffers a resource's x402.accepts is ignored, and without customNetworks a declared network is unknown. Omitting resourceMimeTypes makes challenges declare application/json regardless of resource mimeType; omitting quote disables ERC-8409 signatures.

Solana and NEAR offers need their optional peers installed (@x402/svm and @solana/kit, or @x402/near); the provider loads them only for such an offer.

GatewayOptions

configGatewayConfigrequired
From loadConfig() or parseConfig(raw, env).
storeReceiptStorerequired
receipts({ path }) — SQLite; ':memory:' for tests. Await init() first so an incompatible schema fails at startup; createGateway does not call it. Check a custom store with the conformance cases.
paymentProvidersPaymentProvider[]required
Empty is valid for a gateway serving only free resources.
authorizationProvidersAuthorizationProvider[]
Defaults to none; a resource that requires a method with no provider then fails every request with CONFIG_INVALID.
protocolAdaptersProtocolAdapter[]required
HTTP routes are built in; add MCP, A2A, ACP or your own adapters.
logger / clock / ids / backend
Injectable for tests; backend overrides the HTTP backend executor. A custom executor receives BackendRequest.backendHeaders, the caller headers ACP asks to forward; one that ignores them forwards nothing.

GatewayInstance

MemberPurpose
listen()binds server.host:server.port, resolves { url }
close()stops accepting connections, drains in-flight requests, then stops adapters
pipelinethe ExecutionPipeline every adapter calls
resourcesthe resource registry
serverthe Fastify instance — use .inject() in tests

Test a custom store

The experimental @devlab.group/agent-commerce/testing entry exports ReceiptStore conformance cases using node:assert/strict, without a test runner or optional peers. Run them with your own test framework:

store.test.tstypescript
import { receiptStoreConformanceCases } from '@devlab.group/agent-commerce/testing';
import { it } from 'vitest';

const cases = await receiptStoreConformanceCases(({ clock }) => ({
  store: createMyStore({ clock }),
}));
for (const c of cases) it.skipIf(c.skip !== undefined)(c.name, () => c.run());
  • Return a fresh, empty store per case; the suite calls init() and close(). Use the supplied clock for expiry tests without real-time waits.
  • Version 2.6.0 adds optional idempotency-claim, authorization-hold and pending-task method groups. Implement a whole group or omit it; the suite checks completeness and marks cases needing missing methods to skip. Version 3.0.0 plans to require these groups and findPaymentAttemptByReplayKey.
  • Durability cases skip unless the factory also returns reopen(), which must close the current store and open another over the same data.
  • Concurrency cases require one winner among competing reservations, resolutions, claims and task takes.