목차
- 정말로 방해할 자격이 있는 이벤트
- 준비물
- 1단계 — 이벤트 타입당 채널 하나
- 2단계 — Stripe를 채널로 향하게 하기
- 3단계 — 실제로 도착하는 것
- 4단계 — 의지가 아니라 조건으로 거르기
- 5단계 — 알림 경로를 고장 나는 것 바깥에 두기
- 선택 — 서명을 먼저 검증하기
- 이 구성이 주지 않는 것
- 문제 해결
- 자주 묻는 질문
- 분쟁이 열릴 때 Stripe가 전화를 걸어줄 수 있나요?
- Stripe를 Echobell에 연결하는 데 코드가 필요한가요?
- Stripe에 서드파티 웹훅 URL을 주는 게 안전한가요?
- 왜 알림에 $49.00이 아니라 4900이 뜨나요?
- 테스트 모드 이벤트가 저를 깨우지 않게 하려면?
- 공동창업자가 좌석 비용 없이 같은 알림을 받을 수 있나요?
- 결제 성공에도 알림을 걸어야 하나요?
- 마무리
- 관련 글
분쟁이 열렸다는 것도, 구독 결제가 실패했다는 것도, 정산금이 반송됐다는 것도 Stripe는 이미 알고 있습니다. 그리고 이메일로 알려줍니다. 더 큰 소리가 필요하다면, Stripe에 웹훅 엔드포인트를 하나 더 등록해 Echobell 채널 URL을 가리키게 하고, 이벤트 타입을 몇 개만 구독한 뒤, 각 이벤트에 붙어 있는 마감 시한에 맞춰 알림 타입을 고르면 됩니다. 분쟁과 조기 사기 경고에는 시계가 돌아가고 있고, 갱신 결제 실패에는 대개 그렇지 않습니다.
이 글에서는 어떤 Stripe 이벤트가 당신을 방해할 자격이 있는지, 5분 만에 Stripe를 Echobell에 연결하는 법, 각 객체에 실제로 존재하는 템플릿 필드, 그리고 서명 검증을 건너뛸 때 감수하는 트레이드오프를 다룹니다.
정말로 방해할 자격이 있는 이벤트
결제 알림은 늘 같은 방식으로 망가집니다. 기분이 좋아서 payment_intent.succeeded를 구독하고, 휴대폰이 하루 마흔 번 울리고, 6주 뒤 분쟁 알림이 읽히지 않은 채 지나갑니다. 출발점을 마감 시한으로 바꾸세요. 여덟 시간 늦게 알아도 아무 비용이 없다면, 그건 8초 만에 도착할 필요가 없습니다.
| 이벤트 | 왜 중요한가 | 권장 타입 |
|---|---|---|
charge.dispute.created | 대응할 수 있는 기간이 제한돼 있습니다 — 카드 네트워크에 따라 보통 7~21일. 놓치면 자동 패소입니다. | 전화 |
radar.early_fraud_warning.created | 카드 발급사가 해당 결제가 사기일 수 있다고 Stripe에 알린 상태입니다. 정식 분쟁이 되기 전에 환불하는 것이 아직 남아 있는 선택지이고, 그 창은 짧습니다. | 전화 |
payout.failed | Stripe가 모은 돈이 은행 계좌에 도착하지 못했습니다. 급여든 런웨이 계산이든, 아래로 이어지는 모든 것이 지금 틀렸습니다. | 전화 |
invoice.payment_failed | 비자발적 이탈. 저절로 해결되는 경우도 많아 전화는 과하지만, 금액이 큰 계정은 당일에 확인할 가치가 있습니다. | 시간 민감 |
customer.subscription.deleted | 자발적 이탈. 오늘 알면 충분하고, 깨어날 일은 아닙니다. | 일반 |
payment_intent.succeeded | 아무것도 고장 나지 않았습니다. 위의 다섯 개를 무시하도록 당신을 훈련시키는 게 바로 이 이벤트입니다. | 알림 없음 |
이 단계들은 Echobell의 세 가지 알림 타입에 대응합니다. 일반은 평범한 푸시, 시간 민감은 대부분의 집중 모드를 뚫고, 전화는 수신 전화처럼 표시되어 방해 금지 모드에서도 울립니다. 구독자마다 채널별로 자기 단계를 고르므로, 공동창업자는 분쟁을 전화로 받고 지원 담당자는 같은 것을 푸시로 받을 수 있습니다.
준비물
- Workbench의 Webhooks 탭에 접근 가능한 Stripe 계정
- 설치된 Echobell(App Store / Google Play)
- 5분. 서버도, 배포도, 코드도 필요 없습니다 — 서명 검증을 원한다면 예외이고, 그건 마지막 절에 있습니다.
1단계 — 이벤트 타입당 채널 하나
"Stripe"라는 채널 하나를 만들어 전부 거기로 보내고 싶어집니다. 그러지 마세요. 분쟁, 인보이스, 정산은 각각 다른 객체를 싣고 오기 때문에 본문 템플릿도 대시보드 링크도 달라집니다 — 그리고 이 구성의 핵심은 아무것도 열지 않고 알림만으로 무슨 일이 일어났는지 아는 것입니다.
이벤트 이름을 따서 채널을 만듭니다: Stripe Disputes. 제목과 본문 템플릿은 잠금 화면에서 다 읽히도록 씁니다:
Title: 🔴 Dispute opened — {{data.object.reason}}
Body: Amount: {{data.object.amount}} {{data.object.currency}}
Charge: {{data.object.charge}}
Status: {{data.object.status}}
고급 설정에서 링크 템플릿을 지정해, 알림 기록이 올바른 페이지를 열게 합니다:
https://dashboard.stripe.com/disputes/{{data.object.id}}
그런 다음 전화 타입으로 스스로를 구독하고, 채널 상세 화면에서 웹훅 URL을 복사합니다. https://hook.echobell.one/t/<channel-token> 형태입니다.
2단계 — Stripe를 채널로 향하게 하기
Stripe 대시보드에서 Webhooks 탭을 열고 이벤트 대상을 만듭니다:
Create an event destination을 클릭하고 Your account를 선택한 뒤, API 버전은 계정 기본값 그대로 둡니다.
이벤트 타입은 정확히 하나만 선택합니다 — 이 채널이라면 charge.dispute.created. 통합에 필요한 이벤트만 구독하라는 것은 Stripe 자신의 권고이기도 하고, 여기서는 템플릿을 정직하게 유지해 줍니다. 도착하는 페이로드가 모두 같은 모양이기 때문입니다.
대상 타입으로 Webhook endpoint를 고르고 Echobell 채널 URL을 붙여 넣습니다.
저장한 뒤 Send test event — 또는 CLI의 stripe trigger charge.dispute.created — 로 휴대폰이 실제로 울리는지 확인합니다.
만든 채널마다 반복하세요. Stripe는 계정당 웹훅 엔드포인트를 16개까지 허용하므로 알림 단계별로 하나씩 두기에 충분합니다.
3단계 — 실제로 도착하는 것
Stripe는 Event 객체를 JSON으로 보냅니다. Echobell은 본문을 그대로 읽으므로, 모든 필드를 템플릿과 조건에서 점 표기법으로 접근할 수 있습니다:
{
"id": "evt_1P...",
"type": "charge.dispute.created",
"livemode": true,
"created": 1757548800,
"data": {
"object": {
"id": "dp_1P...",
"amount": 4900,
"currency": "usd",
"reason": "fraudulent",
"status": "needs_response",
"charge": "ch_3P...",
"evidence_details": { "due_by": 1759449600 }
}
}
}
이 페이로드에서 사람들이 놀라는 지점이 세 가지 있습니다.
금액은 최소 화폐 단위의 정수입니다. amount가 4900이면 $49.00입니다. Echobell 템플릿은 값을 끼워 넣고 비교하지만 산술은 하지 않으므로 {{data.object.amount}}는 4900으로 표시됩니다. 단위를 정직하게 표기하거나(Amount: 4900 (cents)), 마지막 절의 포워더에서 보내기 전에 100으로 나누세요.
타임스탬프는 Unix 초입니다. {{data.object.evidence_details.due_by}}는 날짜가 아니라 1759449600으로 렌더링됩니다. 정확한 시각보다 마감이 있다는 사실이 더 중요하다면 템플릿에서 빼고 — 분쟁 페이지에 표시됩니다 — 링크 템플릿이 일하게 하세요.
필드 이름은 객체마다 다릅니다. 분쟁에는 amount가, 인보이스에는 amount_due·customer_email·attempt_count·hosted_invoice_url이, 정산에는 failure_message와 arrival_date가, 조기 사기 경고에는 fraud_type·actionable과 문자열 ID인 charge가 있습니다. 없는 변수는 오류가 아니라 빈 문자열로 렌더링되므로, 다른 채널에서 복사한 템플릿은 조용히 실패합니다. 이것이 "이벤트 타입당 채널 하나"의 현실적인 이유입니다.
4단계 — 의지가 아니라 조건으로 거르기
채널 조건은 템플릿과 같은 표현식 문법을 중괄호 없이 사용하며, 전달 이전에 평가됩니다.
모든 Stripe 채널에 넣어야 할 한 줄:
livemode == true
테스트 모드 트래픽 — 직접 돌린 stripe trigger, 동료가 샌드박스에서 만지는 것 — 이 더 이상 휴대폰에 닿지 않습니다. 배선이 동작하는 것을 확인한 다음에 추가하세요. 그전이 아니라.
인보이스 실패 채널에는 임계값이 소액 계정을 저녁 시간에서 밀어냅니다:
livemode == true && data.object.amount_due > 20000
센트 단위로 "$200 초과"라는 뜻입니다. 첫 시도 실패 전부가 아니라 실제로 막힌 재시도만 보고 싶다면:
livemode == true && data.object.attempt_count > 1
엔드포인트 하나로 여러 이벤트 타입을 한 채널에 보냈다면, 조건으로 다시 나눌 수 있습니다:
type == "charge.dispute.created" || type == "payout.failed"
5단계 — 알림 경로를 고장 나는 것 바깥에 두기
여기가 템플릿보다 값진 부분입니다.
프로덕션 웹훅 엔드포인트는 이행이 일어나는 곳입니다. 접근 권한을 주고, DB에 쓰고, 영수증 메일을 보냅니다. 따라서 그건 앱이 죽을 때 함께 죽는 엔드포인트이기도 합니다. 죽으면 Stripe는 지수 백오프로 최대 3일 동안 재시도하고 이메일을 보냅니다 — 그리고 미전달 웹훅 이메일은 다른 모든 Stripe 이메일과 똑같이 생겨서, 보통 월요일에야 발견됩니다.
웹훅이 조용히 죽는 원인은 시시합니다. Stripe는 3xx 리다이렉트를 실패로 취급하므로, http를 https로 보내거나 끝에 슬래시를 붙이기 시작한 엔드포인트는 이벤트를 받지 못합니다. TLS 1.2 이상이 필요하므로 만료되거나 잘못 설정된 인증서 하나면 충분합니다. 지난주 누군가 추가한 WAF 규칙의 403도 마찬가지입니다.
Echobell을 곧바로 가리키는 두 번째 엔드포인트는 그중 아무것도 공유하지 않습니다. 다른 호스트의 다른 URL이고 인증서도 다르며, 당신의 앱이 쓰러져 있는 동안에도 계속 울립니다. 이 규칙은 일반화됩니다. 뭔가 고장 났다고 알려주는 경로는 그 고장 난 것을 지나가서는 안 된다.
물론 자기 엔드포인트의 실패도 보이길 원할 겁니다. 뭔가 이상하면 Workbench의 Event deliveries 탭을 확인하세요 — 이벤트별로 Delivered·Pending·Failed와 각 시도의 HTTP 상태를 보여줍니다. Stripe는 대시보드에서 15일, CLI의 stripe events resend로는 30일까지 이벤트를 재전송할 수 있으므로 2주 안에 발견한 구멍은 메울 수 있습니다.
Stripe-Signature 헤더를 아무도 검증하지 않는다는 뜻이며, URL 유출은 데이터 유출이 아니라 가짜 알림 기계가 됩니다. 저장소와 스크린샷에서 빼두고, 새어 나갔다면 Reset Token을 쓰세요. 이 트레이드오프가 불편하다면 다음 절을 보세요.선택 — 서명을 먼저 검증하기
Stripe 서명을 실제로 검증하고 금액을 돈처럼 보이게 하고 싶다면, 앞에 작은 포워더를 두세요. 아래 Cloudflare Worker는 이벤트를 검증하고, Stripe가 요구하는 대로 즉시 200을 반환한 뒤, Echobell에는 평평한 페이로드를 보냅니다:
import Stripe from "stripe";
export default {
async fetch(request, env, ctx) {
const stripe = new Stripe(env.STRIPE_SECRET_KEY);
const body = await request.text();
let event;
try {
event = await stripe.webhooks.constructEventAsync(
body,
request.headers.get("stripe-signature"),
env.STRIPE_WEBHOOK_SECRET,
);
} catch {
return new Response("invalid signature", { status: 400 });
}
const invoice = event.data.object;
ctx.waitUntil(
fetch(env.ECHOBELL_HOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
customer: invoice.customer_email || invoice.customer,
amount: (invoice.amount_due / 100).toFixed(2),
currency: invoice.currency.toUpperCase(),
attempt: invoice.attempt_count,
externalLink: invoice.hosted_invoice_url,
}),
}),
);
return new Response("ok", { status: 200 });
},
};
정리를 코드에서 끝냈으니 반대편 템플릿은 훨씬 깔끔해집니다:
Title: 💳 Payment failed — {{currency}} {{amount}}
Body: Customer: {{customer}}
Attempt #{{attempt}}
externalLink는 특수 변수입니다. 링크 템플릿을 설정하지 않으면 Echobell이 이 값을 알림 기록의 링크로 쓰므로, 호스팅된 인보이스 페이지가 한 번의 탭 거리에 놓입니다.
이 트레이드오프의 모양에 주목하세요. 이제 그 자체로 고장 날 수 있는 인프라가 하나 생겼고, 그건 5단계가 경고한 바로 그것입니다. 합리적인 타협은 가짜 알림이 성가실 대량 채널에서는 서명을 검증하고, 분쟁 채널은 직결로 두는 것입니다. 거기서는 잘못 울린 비용이 잠깐의 혼란이고, 놓친 비용은 분쟁 금액입니다.
이 구성이 주지 않는 것
- 온콜 로테이션과 에스컬레이션은 없습니다. 전화 채널을 구독한 모두가 동시에 울립니다. 네 명일 땐 장점, 마흔 명일 땐 문제입니다. 마흔 명이면 인시던트 관리 플랫폼이 필요합니다.
- 중복 제거는 없습니다. Stripe는 이벤트 순서를 보장하지 않으며 같은 이벤트를 두 번 이상 보낼 수 있습니다. 분쟁 하나에 두 번 울릴 수 있습니다.
- 확인 응답은 없습니다. 누가 봤다는 기록도, 아무도 반응하지 않을 때 다음 사람에게 넘기는 장치도 없습니다.
- "결제가 멈췄다" 알림은 없습니다. Stripe는 일이 벌어질 때 이벤트를 내고, 멈출 때는 내지 않습니다. 결제 페이지가 고장 나면 이벤트는 하나도 발생하지 않습니다. 이건 당신 쪽에서 예약 작업을 돌려 지난 한 시간의 결제 건수가 0이면 채널을 울리게 하는 cron 기반 데드맨 스위치가 필요합니다.
문제 해결
Stripe에서는 200인데 알림이 오지 않습니다. Echobell은 전달하지 않을 때도 JSON 본문과 함께 200을 반환합니다 — Event deliveries 탭에서 응답 본문을 보세요. 길이가 맞는 토큰에 success: false면 채널 토큰이 틀린 것입니다. success가 true라면 원인은 대개 조건입니다. livemode == true는 설계대로 모든 테스트 이벤트를 막습니다.
Stripe가 405 Method Not Allowed를 보고합니다. 채널의 POST Only가 켜져 있고 무언가가 GET을 보냈습니다. Stripe 자신은 항상 POST이므로, 이건 링크 미리보기나 브라우저 탭이지 Stripe가 아닙니다.
알림은 오는데 필드가 비어 있습니다. 템플릿이 잘못된 객체를 참조하고 있습니다 — 인보이스 채널에서 {{data.object.amount}}를 썼지만 거기서 필드 이름은 amount_due입니다. 실제 이벤트를 하나 보내고 대시보드에서 열어 JSON을 읽어보세요.
몇 주 잘 되다가 전달이 실패하기 시작합니다. 인증서와 URL 앞단의 리다이렉트를 확인하세요. 채널 URL을 직접 쓸 때는 드물지만, 직접 배포한 포워더라면 늘 첫 번째 용의자입니다.
자주 묻는 질문
분쟁이 열릴 때 Stripe가 전화를 걸어줄 수 있나요?
자체적으로는 못 합니다. Stripe는 이메일, 대시보드, charge.dispute.created 이벤트로 알리고, Stripe Dashboard 앱을 쓴다면 푸시도 보냅니다. 실제로 울리게 하려면 그 이벤트를 구독 타입이 전화인 채널로 보내세요.
Stripe를 Echobell에 연결하는 데 코드가 필요한가요?
아니요. Stripe는 공개된 HTTPS URL이면 어디든 JSON을 POST하고, Echobell 채널 URL이 바로 그것입니다. 코드는 Stripe-Signature 헤더를 검증하거나 금액 형식을 바꾸고 싶을 때만 필요합니다.
Stripe에 서드파티 웹훅 URL을 주는 게 안전한가요?
의식적인 트레이드오프입니다. Stripe가 보내는 페이로드에는 고객과 결제 메타데이터가 들어 있고, Echobell은 원시 웹훅 페이로드를 영구 저장하지 않습니다 — 렌더링된 알림은 기기에 남습니다. 포기하는 것은 서명 검증입니다. URL을 알아낸 사람은 그럴듯한 가짜 알림을 보낼 수 있습니다. API 키처럼 다루고, 검증이 필요한 것에는 포워더 방식을 쓰세요.
왜 알림에 $49.00이 아니라 4900이 뜨나요?
Stripe는 최소 화폐 단위 정수로 금액을 보내고, Echobell 템플릿은 산술을 하지 않습니다. 템플릿에 단위를 표기하거나, 포워더에서 100으로 나눈 뒤 보내세요.
테스트 모드 이벤트가 저를 깨우지 않게 하려면?
채널에 livemode == true 조건을 추가하세요. Stripe는 모든 샌드박스와 stripe trigger 이벤트를 livemode: false로 표시합니다.
공동창업자가 좌석 비용 없이 같은 알림을 받을 수 있나요?
네. 채널 링크를 공유하면 구독자마다 자기 알림 타입을 고릅니다. 한 사람은 분쟁을 전화로, 다른 사람은 일반 푸시로 받을 수 있고 구독자당 과금은 없습니다.
결제 성공에도 알림을 걸어야 하나요?
잠깐만, 그리고 한 건 한 건이 아직 "사건"일 만큼 사업이 작을 때만요. 성공 알림이 일상이 되는 순간부터, 그것은 정말 중요한 알림에 대한 반응을 갉아먹기 시작합니다 — 알림 피로의 핵심 메커니즘입니다.
마무리
전체 구성은 Echobell 채널 하나당 Stripe 이벤트 대상 하나, livemode == true 조건 한 줄, 그리고 전화 단계를 시계가 붙은 이벤트에만 남겨두는 절제입니다. 분쟁과 조기 사기 경고에는 시계가 있습니다. $9 요금제의 갱신 실패에는 없고, 있는 척하는 것이야말로 진짜 시계가 붙은 한 건을 자며 놓치게 되는 이유입니다.
iPhone용 Echobell 다운로드 또는 Google Play에서 받기. 분쟁 채널을 먼저 만들고, 이 경로에 진짜 중요한 것을 맡기기 전에 stripe trigger charge.dispute.created를 한 번 실행해 보세요.