How do I handle Stripe and Lemon Squeezy webhooks idempotently in Next.js?
It depends on your billing providers, but the core architecture is identical: verify the raw payload signature before parsing JSON, normalize provider events into a shared internal contract, and enforce idempotency at the database boundary using the provider's event or order ID. Never grant product entitlements from a client-side checkout redirect; let the verified webhook transactionally update subscription state and entitlements once. supastack prewires this pipeline for Stripe, Lemon Squeezy, and mobile RevenueCat, though single-plan apps can start with a simpler handler.
JSON을 파싱하기 전에 원시 페이로드 서명을 검증하세요
모든 결제 제공업체는 네트워크로 전송하는 정확한 바이트 시퀀스에 서명합니다. 라우트 핸들러가 await request.json()으로 파싱한 뒤 서명을 확인하기 전에 객체를 다시 직렬화하면 키 순서나 공백 차이로 인해 정상적인 웹훅이 거부되거나, 스테이징 환경에서 서명 검증을 건너뛰는 유혹에 빠지기 쉽습니다.
Next.js App Router의 POST 핸들러에서 await request.text()로 원시 본문을 한 번만 읽고, 서버 전용 웹훅 시크릿으로 암호화 서명을 검증한 뒤, 단 하나의 데이터베이스 쿼리도 실행하기 전에 실패한 요청을 HTTP 400으로 거부하세요. 빌링 모듈 가이드는 supastack이 단일 BFF 진입점 뒤에서 서명 검증을 어떻게 분리하는지 보여줍니다.
export async function POST(request: Request) {
const rawBody = await request.text();
const signature = request.headers.get("stripe-signature");
if (!signature) return new Response("Missing signature", { status: 400 });
const event = stripe.webhooks.constructEvent(
rawBody,
signature,
env.STRIPE_WEBHOOK_SECRET,
);
await handleNormalizedBillingEvent(event);
return Response.json({ received: true });
}request.text()로 원시 요청 본문을 읽고, 파싱된 JSON을 다시 문자열로 변환하지 마세요.STRIPE_WEBHOOK_SECRET및 Lemon Squeezy 서명 시크릿은 서버 전용 설정에만 보관하세요.- HMAC 서명을 직접 검증할 때는 상수 시간 비교(
crypto.timingSafeEqual)를 사용하세요.
메모리가 아닌 데이터베이스 경계에서 중복을 제거하세요
Stripe와 Lemon Squeezy는 배포가 재시작되거나 서버리스 함수가 타임아웃되거나 네트워크 구간에서 응답이 유실될 때마다 웹훅을 재전송합니다. 프로세스 로컬 Set이나 짧은 캐시 키는 인스턴스 간에 사라지므로, 동시에 도착한 두 번의 재시도가 중복 라이선스를 발급하거나 영수증 이메일을 두 번 보내는 것을 막지 못합니다.
구독 액세스 권한을 변경하는 동일한 Postgres 트랜잭션 안에서 제공업체의 이벤트 ID나 주문 정산 상태를 기록하세요. 원자적인 INSERT ... ON CONFLICT DO NOTHING이나 status != 'paid' 조건부 업데이트를 사용하면 동시 재전송을 안전한 no-op으로 처리할 수 있습니다. 웹훅이 처리할 요금제를 설계할 때는 SaaS 요금제 페이지 생성기로 티어와 좌석 규칙을 미리 모델링할 수 있습니다.
const settled = await db.transaction(async (tx) => {
const [inserted] = await tx
.insert(billingWebhookEvents)
.values({ providerEventId: event.id, provider: "stripe" })
.onConflictDoNothing({ target: billingWebhookEvents.providerEventId })
.returning({ id: billingWebhookEvents.id });
if (!inserted) return false; // Already processed
await applyWorkspaceEntitlement(tx, event);
return true;
});순서가 바뀐 이벤트와 중복 구독을 안전하게 처리하세요
웹훅이 사용자가 클릭한 순서대로 도착한다는 보장은 없습니다. customer.subscription.updated가 checkout.session.completed보다 먼저 도착할 수도 있고, 실수로 결제 탭을 두 개 연 고객이 같은 워크스페이스에서 잠시 두 개의 구독을 보유할 수도 있습니다.
해지 및 결제 실패 처리는 해당 이벤트를 발생시킨 특정 구독 ID에 한정하세요. 워크스페이스의 유료 권한을 회수하기 전에 해당 워크스페이스를 유지하는 다른 활성 구독이 남아 있는지 확인해야 합니다. 그렇지 않으면 중복 구독을 해지했을 때 고객이 계속 비용을 지불 중인 액세스 권한까지 조용히 사라집니다.
- 구독 상태를
workspaceId뿐만 아니라providerSubscriptionId기준으로 관리하세요. - 최소 하나 이상의 구독이
active또는trialing상태인 동안에는 워크스페이스의 유료 권한을 유지하세요. - 일회성 주문을 처리하기 전에 결제 금액과 통화가 예상 SKU와 일치하는지 확인하세요.
SaaS 빌링 웹훅에 대해 자주 묻는 질문
샌드박스 결제 테스트에서 실제 프로덕션 트래픽으로 전환할 때마다 다음 질문들이 반복해서 등장합니다.
- 결제 성공 리다이렉트 시점에 유료 기능을 바로 열어도 되나요? — 아니요. 리다이렉트 URL은 북마크하거나 공유할 수 있으며, 비동기 결제 수단이 정산되기 전에 방문될 수도 있습니다. 성공 페이지는 서버 상태를 폴링하는 용도로만 사용하고 실제 권한 부여는 검증된 웹훅에 맡기세요.
- 중복 웹훅에는 어떤 상태 코드를 반환해야 하나요? — 데이터베이스에서 이미 처리된 이벤트임이 확인되면
200 OK를 반환하세요.4xx나5xx를 반환하면 제공업체가 계속 재전송합니다. - 로컬에서 웹훅 재시도를 어떻게 테스트하나요? — 완전히 동일한 서명된 페이로드를 두 번 전송하는 결정론적 단위 테스트를 작성하고, 라이선스·권한·트랜잭션 이메일이 딱 한 번만 생성되는지 검증하세요.
- 모바일 인앱 결제는 같은 모델에 어떻게 맞추나요? — RevenueCat 서버 알림을 동일한 멱등성 권한 테이블로 라우팅하여 웹과 모바일 결제가 서로 덮어쓰지 않도록 하세요. 출시 전 점검 항목은 출시 체크리스트에서 확인할 수 있습니다.