Status: detailed spec (v0.1, 2026-05-04). Defines how the buyer's "where to deliver" data is negotiated and exchanged.
Boson exchanges separate payment (commit) from delivery (redeem). Some offers deliver atomically (the resource is the HTTP 200 body); others deliver out-of-band (mailing list, license dispatch, package shipment, off-chain account provisioning). The buyer's required delivery data depends on three orthogonal choices:
- Offer type — atomic / digital-non-atomic / physical.
- Buyer type — human (UI/widget acceptable) vs AI agent (machine-readable channel only).
- Seller policy — what the seller wants to collect, and how.
Rather than fork that complexity into per-seller bespoke flows, x402B standardizes a FulfillmentChannel interface. The 402 advertises which channels the seller supports; the buyer picks one and attaches channel-specific data alongside the payment payload (or out-of-band, depending on channel).
In the escrow PaymentRequirements:
A server may advertise multiple options; the buyer picks one.
The buyer always sends the chosen option at commit time (for capability
negotiation against the server-advertised set). The buyer's delivery
data is action-conditional — it goes in X-PAYMENT for atomic Flow B
because there's only one round trip, and in the redeem POST body for
two-step Flow A because there's a later round trip for it.
The commit and the on-chain redeem ship in a single transaction. The
X-PAYMENT payload carries the chosen option and its delivery data:
"fulfillment": {
"option": "<chosen channel id>",
"data": { /* validates against the chosen option's schema (or null) */ }
}No separate redeem round trip happens, so there's no later opportunity to
hand the seller delivery details. The server's commit handler invokes
channel.onCommit(exchangeId, data) immediately after the atomic redeem
settles on chain.
The X-PAYMENT payload carries only the chosen option:
"fulfillment": {
"option": "<chosen channel id>"
}The buyer attaches data later, in the POST to the boson-redeem
endpoint advertised in the 200 response's nextActions:
{
"exchangeId": "<id>",
"signedPayload": "0x...",
"fulfillment": {
"option": "<chosen channel id>",
"data": { /* validates against the chosen option's schema */ }
}
}The server's redeem handler invokes channel.onCommit(exchangeId, data)
on receipt. The voucher NFT is transferable, so the wallet that signs
boson-redeem may differ from the wallet that committed — whichever
wallet redeems is the one whose delivery data the server persists. The
server still constrains the redeemer's option choice to the original
offer's advertised set so the seller never has to honour a channel they
didn't sign up for.
export interface FulfillmentChannel<TServerCfg = unknown, TBuyerData = unknown> {
/** Stable identifier used in the wire format. */
readonly id: string;
/** JSON-Schema-shaped description of what the buyer must put in `fulfillment.data`. */
readonly buyerDataSchema: Record<string, unknown> | null;
/** Server-side config: keys, urls, etc. */
configure(cfg: TServerCfg): void;
/** Server: build the `options[]` entry for the 402 response. */
describe(): FulfillmentOption; // { id: string; schema: Record<string, unknown> | null; metadata?: unknown }
/** Server: validate the buyer's attached data. */
validate(data: TBuyerData): { ok: true } | { ok: false; reason: string };
/** Server: invoked at commit acceptance — store buyerData against exchangeId. */
onCommit(exchangeId: string, buyerData: TBuyerData): Promise<void>;
/** Server: invoked when the release is observed (REDEEMED on-chain); returns the resource inline or a pointer for async delivery. */
onFulfill(exchangeId: string): Promise<FulfillmentResult>;
/** Client: optionally collect buyer data interactively. */
collect?(metadata: unknown): Promise<TBuyerData>;
}
export type FulfillmentResult =
| { kind: "inline"; body: Uint8Array; contentType: string }
| { kind: "async"; pointer?: string }; // pointer e.g. ipfs://, https://, mailto:id |
Use case | Buyer-data schema | Notes |
|---|---|---|---|
inline |
Resource returned in the same HTTP response | null |
Server onFulfill returns the body inline. Composes naturally with Flow B's atomic commit-and-redeem (the resource is ready), but Flow B does not require this channel — the redeem state transition can be atomic while delivery itself is async. |
email |
Mailing list signup, license key dispatch | { email: string } |
RFC 5321 validation. Server stores against exchangeId; sends on redeem. |
xmtp |
Push to buyer's XMTP inbox | { xmtpAddress: string } (EOA) |
Useful for AI-agent buyers that already use XMTP for commerce. |
webhook |
Push to buyer-controlled HTTPS endpoint | { url: string, authToken?: string, encryptionPubKey?: string } |
See Webhook security below. Server signs the envelope with the key under metadata.serverPublicKey; client verifies signature. |
ipfs-pointer |
Server uploads to IPFS, returns CID | { recipientPubKey?: string } |
Optional encryption to recipientPubKey. Returned on redeem. |
widget |
Human buyer + physical goods (existing Boson Redemption Widget) | null (collected by widget) |
Planned / not yet implemented. metadata.widgetUrl points the human to the existing redemption widget. The widget's existing backend hook is reused unchanged. |
mcp |
AI-agent buyer drives a server-exposed MCP tool | { mcpEndpoint?: string } |
Planned / not yet implemented. The seller's MCP exposes a submit_fulfillment_data(exchangeId, ...) tool. The buyer's agent calls it post-commit. |
The registry is open: third parties can ship additional channels as @bosonprotocol/x402-fulfillment-<id> packages and register them with the SDK at startup.
| Channel | Collected at | Why |
|---|---|---|
inline |
n/a | No data. |
email, xmtp, webhook, ipfs-pointer |
Atomic Flow B: at commit (in X-PAYMENT). Two-step Flow A: at redeem (in the boson-redeem POST body). |
Lightweight; rides with whichever step is the last buyer round trip. |
widget, mcp |
Post-commit, via the channel itself | Heavier UI / agent-driven; not a fit for header-sized data. |
Servers SHOULD advertise at least one in-payload-shaped fulfillment channel (email/xmtp/webhook). Headless agents using atomic Flow B complete in one round trip (commit-and-redeem with data); agents using two-step Flow A complete in two (commit, then redeem with data).
fulfillment.datais stored server-side keyed by exchangeId. Servers MUST NOT include it in any unauthenticated channel.- For
webhook, the buyer'sencryptionPubKeyMAY be used to encrypt the resource payload (out of scope for v1; specced in03b-webhook-encryption.mdlater). - Sellers should expose a deletion endpoint per their privacy policy; not in the protocol.
The webhook channel hands the seller a buyer-controlled HTTPS URL — the buyer's endpoint can become a target as soon as the URL is known. The protocol layers three independent protections; the buyer SHOULD use all three:
- Server signature (always on). The seller signs every webhook envelope with the key advertised under
metadata.serverPublicKey. The envelope MUST include theexchangeIdand a millisecondtimestamp. The buyer MUST verify the signature, MUST reject envelopes whosetimestampis older than a small freshness window (recommended: ≤ 300 s), and MUST treat repeated deliveries with the sameexchangeIdas idempotent (one logical delivery, dedupe on the buyer's side). - Bearer token (optional, buyer-published). When the buyer sets
authToken, the seller MUST send it asAuthorization: Bearer <authToken>on every webhook request. This lets the buyer's edge layer reject unauthenticated traffic before any signature work — useful against random POSTers and leaked-URL scanning. The token travels withfulfillment.data(atomicboson-createOfferCommitAndRedeemFlow B: commit-timeX-PAYMENT; two-step Flow A: redeem-time POST body), so it offers no protection against an attacker who can read that traffic; pair it with (1) for end-to-end authenticity. - Encryption to buyer (optional, buyer-published). When the buyer sets
encryptionPubKey, the seller MAY encrypt the resource body to that key (cipher specified in03b-webhook-encryption.md; not yet implemented). Protects confidentiality against any actor with the URL but without the buyer's private key.
Seller adapters MUST refuse plain http:// URLs — TLS is mandatory for the transport. Buyers SHOULD also rotate authToken per offer / per session if the same endpoint is reused across many exchanges.
- If
requirements.fulfillment.required === true,payload.fulfillment.optionMUST be present and MUST match an advertised channel id fromrequirements.fulfillment.options[].id. - Action-conditional
payload.fulfillment.data:- For
payload.action = boson-createOfferCommitAndRedeem(atomic Flow B):dataMUST be present. The chosen option'sbuyerDataSchemaMUST validatedata(ornullwhen the schema isnull). The server-side channel instance MUST be configured (at boot, not per-request). - For
payload.action = boson-createOfferAndCommit(two-step Flow A):dataMUST be absent — it's reserved for the redeem POST body.
- For
- Flow B only: after the atomic redeem confirms on chain, the commit handler records a pending fulfillment update and calls
channel.onCommit(exchangeId, data)to persist the buyer's delivery target. A successful write clears the pending update. A failing channel write leaves the pending update for host-side replay/reconciliation and surfaces aFULFILLMENT_COMMIT_DEFERREDwarning on the 200 response; the on-chain state is already irreversiblyREDEEMED. - Flow A only: the server persists the advertised option ids against the exchange so the redeem-time check below can constrain the buyer's choice when the voucher has been transferred between commit and redeem.
- The chosen option's
buyerDataSchemaMUST validate the redeem-time body'sfulfillment.data(ornullif schema isnull). - The server-side instance of the channel MUST be configured (at boot, not per-request).
- After redeem confirmation, server calls
channel.onCommit(exchangeId, data)to persist the buyer's delivery target. A failing channel write surfaces as aFULFILLMENT_UPDATE_DEFERREDwarning on the 200 response.
In the two-step Flow A (boson-createOfferAndCommit followed later by boson-redeem) the voucher NFT is transferable, so the wallet that signs boson-redeem may differ from the wallet that committed. Because buyer-supplied delivery data flows only at redeem time, this is benign: whichever wallet signs the redeem is the wallet whose delivery target the server persists. There's no committer-vs-redeemer rebinding gate — the redeemer just supplies whatever fulfillment the seller's advertised set permits.
When a redeem request carries fulfillment, option MUST be one of the options advertised in the original 402 for that exchange (the server enforces this against its persisted option-policy entry). The server runs channel.validate(data) and then channel.onCommit(exchangeId, data) — channels treat onCommit as an upsert. The redeem-time wire shape is { option: string, data: <schema-of-option> | null }.
Because the on-chain redeem is irreversible, servers record a pending fulfillment update before the post-redeem channel upsert and clear it only after onCommit succeeds. If that upsert fails, the redeem response still reports the successful transaction and includes a warning such as FULFILLMENT_UPDATE_DEFERRED, leaving the pending update for host-side replay/reconciliation.
Flow B (boson-createOfferCommitAndRedeem, atomic) is symmetric: the buyer's delivery data rides with the commit-time payload (see Atomic Flow B) and the commit handler invokes channel.onCommit after the on-chain redeem settles. A failing onCommit leaves a pending fulfillment update for host-side replay/reconciliation and surfaces as a FULFILLMENT_COMMIT_DEFERRED warning on the 200 response.
import { negotiateFulfillment } from "@bosonprotocol/x402-fulfillment";
const choice = await negotiateFulfillment(requirements.fulfillment.options, {
prefer: ["inline", "xmtp", "email"], // for AI agents
collectInteractive: ui?.collect, // for humans
agentContext: { xmtpAddress: agentWallet.xmtp }, // pre-known data
});
// choice = { option: "xmtp", data: { xmtpAddress: "0x..." } }negotiateFulfillment returns the first option that (a) the client supports, (b) the client has data for (or can collect), and (c) is acceptable to the agent's policy.
- Multi-step deliveries (a license + an email confirmation): for v1, sellers split into separate offers. Multi-channel per offer is a v2 feature.
- Data-at-rest encryption for
email/webhookstorage on the server: out of scope; sellers handle per their stack. - Channel advertising in seller metadata (so a buyer can pre-filter sellers by accepted fulfillment channels): see boson-impl-09-seller-metadata.md.