x402 결제는 API의 402 응답에 결제 조건을 담아 구현합니다. Node.js와 테스트 지갑을 준비하세요. 먼저 Base Sepolia에서 v2 서명과 정산을 검증합니다. 유료 응답은 정산 성공 뒤에 전달하도록 설정하세요.
금액·체인 정보는 출처에 연결한 x402·Base·Circle 공식 문서 기준입니다.
실습 준비와 설치
API를 수정할 수 있는 개발 환경과 테스트 지갑을 준비하세요. 예제는 Node.js 22와 x402 SDK 2.28.0을 기준으로 구성했습니다. 설치 구조는 공식 판매자 가이드를 따릅니다.
- 결제를 받을 EVM 지갑 주소를 준비하세요.
- 결제용 테스트 지갑을 별도로 준비하세요.
- 결제용 지갑에 Base Sepolia 테스트 USDC를 확보하세요.
- 아래 명령으로 실습 프로젝트를 만드세요.
- 이후의 TypeScript 파일을 같은 폴더에 저장하세요.
mkdir x402-demo
cd x402-demo
npm init -y
npm pkg set type=module
npm install --save-exact [email protected] [email protected] @x402/[email protected] @x402/[email protected] @x402/[email protected] @x402/[email protected]
npm install --save-dev --save-exact typescript tsx @types/node @types/express
수취 서버에는 수취 지갑의 개인키가 필요하지 않습니다. 결제용 개인키는 클라이언트 프로세스에서만 읽도록 구성하세요. 지갑 서명 방식은 공식 구매자 가이드를 기준으로 합니다.
이 예제의 검증 범위는 타입 검사와 모의 결제 흐름입니다. 실제 테스트넷 송금까지 완료된 예제는 아닙니다. 아래 실행 절차에서 온체인 결과를 직접 확인하세요.
중복 요청 복구를 시험해 결제 재시도가 이중 정산으로 이어지지 않는지 확인하세요.
x402 v1과 v2 비교
v2를 구현할 때는 헤더와 본문 필드를 함께 맞춰야 합니다. 헤더 이름만 바꾸면 데이터 구조가 맞지 않습니다. 다음 표는 공식 이전 가이드를 기준으로 합니다.
| 구분 | v1 | v2 |
|---|---|---|
| 프로토콜 버전 | x402Version: 1 |
x402Version: 2 |
| 최초 결제 조건 | 402 응답의 JSON 본문 |
PAYMENT-REQUIRED 응답 헤더 |
| 서명한 결제 데이터 |
X-PAYMENT 요청 헤더 |
PAYMENT-SIGNATURE 요청 헤더 |
| 정산 결과 |
X-PAYMENT-RESPONSE 응답 헤더 |
PAYMENT-RESPONSE 응답 헤더 |
| Base 메인넷 식별자 | base |
eip155:8453 |
| Base Sepolia 식별자 | base-sepolia |
eip155:84532 |
| 결제 조건의 금액 필드 | maxAmountRequired |
amount |
| 리소스 설명 | 결제 조건 안의 resource 등 |
최상위 resource 객체 |
| 결제 페이로드의 조건 | 최상위 scheme, network 등 |
선택한 조건을 담는 accepted 객체 |
| TypeScript 패키지 예 | x402-express |
@x402/express |
v1의 결제 조건 전달 위치는 HTTP v1 사양에 명시돼 있습니다. v2의 표준 전달 위치는 PAYMENT-REQUIRED 헤더입니다. 구현체가 본문도 반환할 수 있지만 헤더를 기준으로 처리하세요.
HTTP v2 사양은 상태 코드를 다음과 같이 설명합니다.
The server indicates payment is required using the HTTP 402 "Payment Required" status code.
출처: x402 HTTP v2 사양
v2 결제 헤더는 JSON을 Base64로 인코딩한 값입니다. Base64는 암호화 방식이 아닙니다. 외부 서비스에서는 HTTPS를 사용하세요.
HTTP 402 요청과 결제 순서
이 실습은 EVM exact 방식의 기본 승인 흐름을 사용합니다. exact는 정해진 금액을 결제하는 방식입니다. 순서는 x402 v2 핵심 사양에 따릅니다.
- 클라이언트가 결제 정보 없이 유료 API를 요청합니다.
- 서버가 HTTP 402와
PAYMENT-REQUIRED를 반환합니다. - 클라이언트가
accepts에서 허용된 조건을 선택합니다. - SDK가 선택한 조건에 맞는 결제 승인을 서명합니다.
- 클라이언트가
PAYMENT-SIGNATURE를 붙여 재요청합니다. - 서버가 facilitator의
/verify로 승인을 검증합니다. - 검증을 통과하면 서버가 리소스 처리를 실행합니다.
- 서버가
/settle로 정산을 요청합니다. - 정산 성공 뒤 유료 본문과
PAYMENT-RESPONSE를 전달합니다.
이 순서는 모든 결제 방식에 공통인 고정 순서가 아닙니다. v2에는 정산 시점이 다른 흐름도 정의돼 있습니다. 아래 코드는 기본 authorization 흐름만 허용합니다.
Resource server와 facilitator의 역할
Resource server는 가격과 콘텐츠 접근 조건을 결정합니다. Facilitator는 결제 검증과 정산을 담당합니다. 역할 구분은 공식 facilitator 문서를 기준으로 합니다.
| 구성 요소 | 맡는 일 | 성공으로 간주하면 안 되는 상태 |
|---|---|---|
| 클라이언트 | 조건 선택과 결제 승인 서명 | 서명 생성만 완료된 상태 |
| Resource server | 가격 선언과 유료 응답 통제 | 클라이언트가 성공했다고 주장하는 상태 |
Facilitator /verify
|
서명과 결제 조건의 유효성 확인 |
isValid: true만 받은 상태 |
Facilitator /settle
|
이 실습에서는 온체인 결제 실행과 결과 반환 | 전송했지만 결과를 확인하지 못한 상태 |
/verify는 온체인 상태를 변경하지 않는 검사입니다. 따라서 검증 성공은 입금 완료를 뜻하지 않습니다. 검증 이후에도 잔액이나 승인 상태가 달라질 수 있습니다.
검증이나 정산이 실패하면 유료 본문을 차단하세요. 성공을 확인하지 못했을 때 차단하는 처리를 fail-closed라고 합니다. 이 동작은 공식 Express 구현에서도 확인할 수 있습니다.
Base 메인넷과 테스트넷 비교
첫 실행에서는 Base Sepolia만 사용하세요. Circle은 테스트넷 USDC에 금융 가치가 없다고 명시합니다. 주소는 Circle의 USDC 컨트랙트 목록을 기준으로 합니다.
| 항목 | Base 메인넷 | Base Sepolia 테스트넷 |
|---|---|---|
| 용도 | 실제 자산 결제 | 구현과 장애 처리 시험 |
| Chain ID | 8453 |
84532 |
| x402 v2 네트워크 | eip155:8453 |
eip155:84532 |
| USDC 컨트랙트 | 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 |
0x036CbD53842c5426634e7929541eC2318f3dCF7e |
| 공개 RPC | https://mainnet.base.org |
https://sepolia.base.org |
| 토큰의 금융 가치 | 실제 금융 가치가 있는 자산 | 금융 가치 없음 |
Chain ID와 RPC는 Base 네트워크 문서에서 확인하세요. 같은 지갑 주소라도 체인이 다르면 잔액이 별개입니다. 테스트넷 성공을 실제 매출로 기록하면 안 됩니다.
테스트 USDC는 Circle Testnet Faucet에서 요청할 수 있습니다. 네트워크는 Base Sepolia로 선택하세요. 수령 주소에는 결제용 테스트 지갑 주소를 입력합니다.
쿠팡 파트너스 광고 · 일정액의 수수료를 받습니다.
Node.js 유료 API 구현
유료 경로 앞에 paymentMiddleware를 연결하세요. 아래 예제는 호출당 0.001 USDC를 요구합니다. 금액은 공식 판매자 가이드의 예시 가격을 사용합니다. (확인값 호출당 0.001 USDC · 출처 docs.x402.org · 2026-10-28 확인)
서버 파일
다음 코드를 server.ts로 저장하세요. 서버는 로컬 인터페이스에서만 요청을 받습니다. 수취 주소는 PAY_TO 환경변수에서 읽습니다.
import express from "express";
import { getAddress, zeroAddress } from "viem";
import { paymentMiddleware } from "@x402/express";
import { HTTPFacilitatorClient, x402ResourceServer } from "@x402/core/server";
import { ExactEvmScheme } from "@x402/evm/exact/server";
const payTo = getAddress(process.env.PAY_TO ?? "");
if (payTo === zeroAddress) throw new Error("Invalid payTo");
const network = "eip155:84532";
const resourceServer = new x402ResourceServer(
new HTTPFacilitatorClient({ url: "https://x402.org/facilitator" }),
).register(network, new ExactEvmScheme());
const app = express();
app.use((_req, res, next) => {
res.setHeader("Cache-Control", "private, no-store");
next();
});
app.use(paymentMiddleware({
"GET /paid-data": {
accepts: [{ scheme: "exact", price: "$0.001", network, payTo }],
description: "Sample paid JSON",
mimeType: "application/json",
},
}, resourceServer));
app.get("/paid-data", (_req, res) => {
res.json({ content: "Paid sample content" });
});
app.listen(4021, "127.0.0.1");
https://x402.org/facilitator는 공식 가이드의 테스트용 주소입니다. 이 예제에서는 Base Sepolia 정산에 사용합니다. 운영용 facilitator 선택은 판매자 가이드의 메인넷 절차를 확인하세요.
가격 계산 예시
예제 가격은 USDC 최소 단위로 1000입니다. USDC의 소수 자릿수는 6자리입니다. SDK의 가격 변환은 EVM 서버 구현을 기준으로 합니다.
0.001 USDC × 10^6 = 1000 최소 단위
| 확인할 값 | 이 예제의 기대값 |
|---|---|
서버 설정의 price
|
"$0.001" |
402 결제 조건의 amount
|
"1000" |
결제 조건의 asset
|
Base Sepolia USDC 컨트랙트 |
결제 조건의 payTo
|
설정한 수취 주소 |
결제 조건의 network
|
eip155:84532 |
price는 SDK 설정값입니다. 실제 v2 결제 조건에는 amount가 전달됩니다. 정수 금액은 문자열이나 bigint로 비교하세요.
서버 실행과 402 확인
첫 번째 Bash 터미널에서 서버를 실행하세요. 입력하는 값은 개인키가 아닌 공개 수취 주소입니다. 실행 중에는 이 터미널을 유지합니다.
read -r -p '수취 지갑 주소: ' PAY_TO
export PAY_TO
npx tsx server.ts
두 번째 터미널에서 무결제 요청을 보내세요. 아래 명령은 결제를 서명하지 않습니다. HTTP 상태와 디코딩한 결제 조건을 출력합니다.
node --input-type=module <<'JS'
const response = await fetch("http://127.0.0.1:4021/paid-data");
const header = response.headers.get("PAYMENT-REQUIRED");
console.log(response.status);
console.log(header && JSON.parse(
Buffer.from(header, "base64").toString("utf8")
));
JS
정상적인 무결제 응답은 HTTP 402입니다. 출력된 조건을 위 표와 대조하세요. 유료 콘텐츠가 바로 반환되면 경로 보호 설정부터 수정합니다.
결제 조건 선택과 클라이언트 구현
허용한 조건에만 서명하도록 클라이언트를 제한하세요. 이 예제는 Base Sepolia USDC의 정해진 가격만 허용합니다. 결제 옵션 필터는 공식 x402Client 구현의 registerPolicy를 사용합니다.
- 아래 코드를
client.ts로 저장하세요. - 다음 절의
check-transfer.ts도 저장하세요. - 두 파일을 준비한 뒤 실행 절차를 진행하세요.
import { writeFile } from "node:fs/promises";
import { getAddress, zeroAddress, type Hex, type Hash } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { x402Client, wrapFetchWithPayment } from "@x402/fetch";
import { decodePaymentResponseHeader } from "@x402/core/http";
import { ExactEvmScheme } from "@x402/evm/exact/client";
import { checkTransfer } from "./check-transfer.js";
const key = process.env.EVM_PRIVATE_KEY ?? "";
if (!/^0x[0-9a-fA-F]{64}$/.test(key)) throw new Error("Invalid test key");
const signer = privateKeyToAccount(key as Hex);
const payTo = getAddress(process.env.PAY_TO ?? "");
if (payTo === zeroAddress) throw new Error("Invalid payTo");
const network = "eip155:84532";
const token = "0x036CbD53842c5426634e7929541eC2318f3dCF7e";
const client = new x402Client().register(network, new ExactEvmScheme(signer));
client.registerPolicy((version, options) => version === 2
? options.filter(r =>
r.scheme === "exact" && r.network === network &&
r.asset.toLowerCase() === token.toLowerCase() &&
r.payTo.toLowerCase() === payTo.toLowerCase() && r.amount === "1000" &&
(!r.extra?.assetTransferMethod || r.extra.assetTransferMethod === "eip3009") &&
(!r.extra?.paymentFlow || r.extra.paymentFlow === "authorization"))
: []);
const paidFetch = wrapFetchWithPayment(fetch, client);
const response = await paidFetch("http://127.0.0.1:4021/paid-data", {
redirect: "error",
});
const raw = response.headers.get("PAYMENT-RESPONSE");
if (!raw) throw new Error(`No settlement receipt: HTTP ${response.status}`);
const settlement = decodePaymentResponseHeader(raw);
await writeFile("settlement.json", JSON.stringify(settlement, null, 2));
if (!response.ok || !settlement.success || settlement.network !== network) {
throw new Error("Payment unresolved; inspect settlement.json before retrying");
}
const evidence = await checkTransfer(
network, settlement.transaction as Hash, signer.address, payTo, 1000n,
);
await writeFile("transfer.json", JSON.stringify(evidence, null, 2));
console.log(await response.text());
wrapFetchWithPayment가 402 처리와 서명 재요청을 담당합니다. 사용 방식은 공식 fetch 예제를 따릅니다. 허용된 결제 조건이 없으면 결제 생성을 중단합니다.
AI 에이전트에서도 이 클라이언트를 사용할 수 있습니다. 다만 호출당 가격 제한은 누적 지출 제한이 아닙니다. 에이전트의 반복 호출 예산은 별도로 관리하세요.
USDC Transfer 이벤트 검증
정산 결과의 거래 해시로 USDC 전송 로그를 확인하세요. 거래 성공 상태만으로 수취인까지 확인되지는 않습니다. Transfer 필드는 ERC-20 표준을 기준으로 합니다.
검증에는 다음 항목이 필요합니다.
| 검증 항목 | 비교 대상 |
|---|---|
| RPC의 Chain ID | 서버가 허용한 체인 |
거래 영수증의 status
|
성공 상태 |
| 이벤트를 발생시킨 주소 | 해당 체인의 공식 USDC 컨트랙트 |
Transfer.from |
확인된 결제자 |
Transfer.to |
서버가 정한 수취 주소 |
Transfer.value |
서버가 요구한 최소 단위 금액 |
아래 코드를 check-transfer.ts로 저장하세요. 조회는 viem의 getTransactionReceipt를 사용합니다. 이벤트 해석은 parseEventLogs를 기준으로 합니다.
import {
createPublicClient, http, getAddress, parseEventLogs,
erc20Abi, type Address, type Hash,
} from "viem";
import { base, baseSepolia } from "viem/chains";
const configs = {
"eip155:84532": {
chain: baseSepolia,
rpc: "https://sepolia.base.org",
token: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
},
"eip155:8453": {
chain: base,
rpc: "https://mainnet.base.org",
token: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
},
} as const;
export async function checkTransfer(
network: keyof typeof configs, hash: Hash,
payer: Address, payTo: Address, amount: bigint,
) {
const cfg = configs[network];
const rpc = createPublicClient({ chain: cfg.chain, transport: http(cfg.rpc) });
if (await rpc.getChainId() !== cfg.chain.id) throw new Error("Wrong chain");
const receipt = await rpc.getTransactionReceipt({ hash });
if (receipt.status !== "success") throw new Error("Transaction failed");
const logs = parseEventLogs({
abi: erc20Abi,
eventName: "Transfer",
logs: receipt.logs.filter(
log => getAddress(log.address) === getAddress(cfg.token),
),
strict: true,
});
const transfer = logs.find(log =>
getAddress(log.args.from) === getAddress(payer) &&
getAddress(log.args.to) === getAddress(payTo) &&
log.args.value === amount,
);
if (!transfer) throw new Error("Matching USDC Transfer not found");
return {
network, transaction: hash, logIndex: transfer.logIndex,
blockHash: receipt.blockHash, blockNumber: receipt.blockNumber.toString(),
};
}
이 함수는 해당 거래의 USDC 전송 사실을 확인합니다. 특정 API 주문에 귀속됐는지는 별도로 확인해야 합니다. 운영 서버에서는 저장된 주문 조건을 비교값으로 사용하세요.
클라이언트가 올린 transfer.json을 접근 허가 증거로 믿으면 안 됩니다. 이 예제의 서버는 facilitator의 검증·정산 결과를 사용합니다. 독립 조회를 접근 허가 조건으로 삼으려면 서버에서 실행해야 합니다.
영수증 조회 실패는 곧 결제 실패를 뜻하지 않습니다. RPC 오류라면 저장된 거래 해시를 다시 조회하세요. 추가 확인 수와 체인 재구성 대응은 운영 정책으로 정합니다.