350 lines
8.1 KiB
Markdown
350 lines
8.1 KiB
Markdown
# Les 17 — Huiswerk
|
|
## Webhook + Resend email + retry + APIS.md
|
|
|
|
**Deadline:** Voor Les 18 — Supabase Auth + RLS
|
|
**Inleveren:** GitHub repo + `APIS.md` in root
|
|
|
|
---
|
|
|
|
## Doel
|
|
|
|
Bouw je Stripe-demo uit naar een echte productie-flow: betaling triggert webhook → database update → welkomstmail. Plus retry-logic en eindopdracht-reflectie.
|
|
|
|
---
|
|
|
|
## Onderdeel A — Webhook handler met signature verify (verplicht)
|
|
|
|
### Stripe CLI installeren
|
|
|
|
```bash
|
|
# macOS
|
|
brew install stripe/stripe-cli/stripe
|
|
# of via https://docs.stripe.com/stripe-cli
|
|
|
|
stripe login
|
|
```
|
|
|
|
### Lokaal webhook luisteren
|
|
|
|
```bash
|
|
stripe listen --forward-to localhost:3000/api/webhook/stripe
|
|
```
|
|
|
|
Output: `Ready! Your webhook signing secret is whsec_xxx`. Kopieer naar `.env.local`:
|
|
|
|
```
|
|
STRIPE_WEBHOOK_SECRET=whsec_xxx
|
|
```
|
|
|
|
### Webhook endpoint
|
|
|
|
`app/api/webhook/stripe/route.ts`:
|
|
|
|
```typescript
|
|
import { headers } from "next/headers";
|
|
import Stripe from "stripe";
|
|
|
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
|
|
|
|
export async function POST(req: Request) {
|
|
const body = await req.text();
|
|
const sig = (await headers()).get("stripe-signature")!;
|
|
|
|
let event: Stripe.Event;
|
|
try {
|
|
event = stripe.webhooks.constructEvent(
|
|
body, sig, process.env.STRIPE_WEBHOOK_SECRET!
|
|
);
|
|
} catch (err: any) {
|
|
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
|
|
}
|
|
|
|
// Idempotency check
|
|
// const existing = await checkProcessedEvent(event.id);
|
|
// if (existing) return Response.json({ received: true });
|
|
|
|
console.log("Stripe event:", event.type);
|
|
|
|
if (event.type === "checkout.session.completed") {
|
|
const session = event.data.object;
|
|
console.log("Payment from:", session.customer_email);
|
|
// TODO: Insert in DB, send email
|
|
}
|
|
|
|
return Response.json({ received: true });
|
|
}
|
|
```
|
|
|
|
### Test
|
|
|
|
Met `stripe listen` aan + dev server aan, doe een test-betaling. Check console output van beide processen.
|
|
|
|
### Eisen
|
|
|
|
- [ ] Webhook endpoint werkt
|
|
- [ ] Signature verification actief (test door secret te wijzigen — moet falen)
|
|
- [ ] `checkout.session.completed` event wordt herkend
|
|
- [ ] Console toont event-data
|
|
|
|
---
|
|
|
|
## Onderdeel B — Resend welkomstmail (verplicht)
|
|
|
|
### Setup
|
|
|
|
```bash
|
|
pnpm add resend
|
|
```
|
|
|
|
Maak account op https://resend.com, kopieer API key.
|
|
|
|
`.env.local`:
|
|
```
|
|
RESEND_API_KEY=re_...
|
|
```
|
|
|
|
### Email versturen in webhook
|
|
|
|
```typescript
|
|
import { Resend } from "resend";
|
|
|
|
const resend = new Resend(process.env.RESEND_API_KEY!);
|
|
|
|
// In webhook handler, na event.type check:
|
|
if (event.type === "checkout.session.completed") {
|
|
const session = event.data.object;
|
|
if (session.customer_email) {
|
|
await resend.emails.send({
|
|
from: "Premium <onboarding@resend.dev>",
|
|
to: session.customer_email,
|
|
subject: "Welkom bij Premium!",
|
|
html: `<h1>Welkom!</h1><p>Bedankt voor je aanmelding.</p>`,
|
|
});
|
|
console.log("Email sent to:", session.customer_email);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Optioneel: React Email
|
|
|
|
Voor mooiere templates:
|
|
|
|
```bash
|
|
pnpm add react-email @react-email/components
|
|
```
|
|
|
|
`emails/WelcomeEmail.tsx`:
|
|
```tsx
|
|
import { Html, Heading, Text, Button } from "@react-email/components";
|
|
|
|
export function WelcomeEmail({ email }: { email: string }) {
|
|
return (
|
|
<Html>
|
|
<Heading>Welkom!</Heading>
|
|
<Text>Je account ({email}) is geactiveerd.</Text>
|
|
<Button href="https://example.com/dashboard">Open dashboard</Button>
|
|
</Html>
|
|
);
|
|
}
|
|
```
|
|
|
|
In webhook:
|
|
```typescript
|
|
await resend.emails.send({
|
|
from: "...", to: session.customer_email!,
|
|
subject: "Welkom!",
|
|
react: WelcomeEmail({ email: session.customer_email! }),
|
|
});
|
|
```
|
|
|
|
### Eisen
|
|
|
|
- [ ] Email wordt verstuurd bij succesvolle betaling
|
|
- [ ] Email komt aan in inbox (check je eigen email als test)
|
|
- [ ] Resend dashboard toont email in logs
|
|
|
|
---
|
|
|
|
## Onderdeel C — Retry-logic (verplicht)
|
|
|
|
Maak een retry-helper en gebruik 'm voor minimaal één externe call.
|
|
|
|
`lib/retry.ts`:
|
|
```typescript
|
|
export async function retry<T>(
|
|
fn: () => Promise<T>,
|
|
attempts = 3,
|
|
baseDelayMs = 1000
|
|
): Promise<T> {
|
|
for (let i = 0; i < attempts; i++) {
|
|
try {
|
|
return await fn();
|
|
} catch (err) {
|
|
if (i === attempts - 1) throw err;
|
|
const delay = baseDelayMs * Math.pow(2, i);
|
|
console.log(`Retry ${i + 1}/${attempts} na ${delay}ms`);
|
|
await new Promise(r => setTimeout(r, delay));
|
|
}
|
|
}
|
|
throw new Error("unreachable");
|
|
}
|
|
```
|
|
|
|
Gebruik:
|
|
```typescript
|
|
const session = await retry(() =>
|
|
stripe.checkout.sessions.create({...})
|
|
);
|
|
```
|
|
|
|
### Demonstratie van een falende call
|
|
|
|
Voor je `APIS.md` (zie D): induceer expres een fail om retry te laten zien. Bv. wrong URL of fake timeout.
|
|
|
|
### Eisen
|
|
|
|
- [ ] `retry()` helper bestaat en werkt
|
|
- [ ] Wordt gebruikt op minimaal één externe call
|
|
- [ ] Log laat zien wanneer retries gebeuren
|
|
- [ ] In APIS.md: 1 voorbeeld van failed call + retry succes
|
|
|
|
---
|
|
|
|
## Onderdeel D — `APIS.md` (verplicht)
|
|
|
|
In repo-root.
|
|
|
|
### Sectie 1 — Welke APIs gebruikt jouw app
|
|
|
|
Tabel:
|
|
|
|
| API | Waarvoor | Auth | Cost |
|
|
|-----|----------|------|------|
|
|
| Stripe | Betalingen | secret key + webhook | 1.4% + €0.25 |
|
|
| Resend | Transactional email | api key | gratis tier 3k/maand |
|
|
|
|
### Sectie 2 — Webhook setup
|
|
|
|
Stappen die je nam:
|
|
- Stripe CLI installeren
|
|
- `stripe listen` command
|
|
- Endpoint code (link naar file)
|
|
- Signature verify (screenshot van falende verify als bewijs)
|
|
|
|
### Sectie 3 — Voorbeeld successful flow
|
|
|
|
End-to-end:
|
|
1. Pricing page klik
|
|
2. Stripe Checkout (screenshot)
|
|
3. Test-betaling (screenshot)
|
|
4. Console output van webhook
|
|
5. Email screenshot
|
|
6. Stripe dashboard transactie (screenshot)
|
|
|
|
### Sectie 4 — Failed call met retry
|
|
|
|
Voorbeeld waar retry redde:
|
|
```
|
|
Attempt 1: Failed (network timeout)
|
|
Attempt 2: Failed (network timeout)
|
|
Attempt 3: Success — session created
|
|
Total time: 4.2s
|
|
```
|
|
|
|
Welke fout je induceerde + waarom retry hielp.
|
|
|
|
### Sectie 5 — Eindopdracht-toepassing
|
|
|
|
Concrete plannen voor je eigen eindopdracht:
|
|
- Welke externe API ga je gebruiken?
|
|
- Waarom die?
|
|
- Webhook nodig? Welk event?
|
|
- Email-flows die je wilt?
|
|
- Wat ga je doen aan rate limiting?
|
|
|
|
Minstens 200 woorden, concreet.
|
|
|
|
### Vorm
|
|
|
|
- Max 800 woorden totaal
|
|
- Screenshots zijn waardevol
|
|
- Concrete code-references (links naar files)
|
|
|
|
---
|
|
|
|
## Bonus (optioneel)
|
|
|
|
### Bonus 1 — OAuth login met Auth.js
|
|
|
|
Voeg GitHub login toe met Auth.js. Documenteer setup-stappen.
|
|
|
|
### Bonus 2 — Idempotency in webhook
|
|
|
|
Sla event.id op in Supabase, skip als al verwerkt. Documenteer waarom belangrijk.
|
|
|
|
### Bonus 3 — Rate limiting
|
|
|
|
Voeg Upstash Ratelimit toe aan je `/api/checkout` endpoint. 5 calls per minuut per IP.
|
|
|
|
### Bonus 4 — Refund flow
|
|
|
|
Webhook ook luisteren naar `charge.refunded` event. Email sturen + DB update.
|
|
|
|
---
|
|
|
|
## Inleveren
|
|
|
|
1. **GitHub repo URL** in Brightspace
|
|
2. **`APIS.md`** in repo-root (5 secties)
|
|
3. **Working Stripe + Resend** demonstreerbaar
|
|
4. **Screenshots** in APIS.md van flow + Stripe + Resend dashboards
|
|
|
|
---
|
|
|
|
## Beoordeling
|
|
|
|
| Criterium | Punten |
|
|
|-----------|--------|
|
|
| A — Webhook werkt + signature verify | 3 |
|
|
| B — Resend email bij betaling | 2 |
|
|
| C — Retry helper + bewijs werking | 2 |
|
|
| D — APIS.md compleet (5 secties) | 3 |
|
|
| **Totaal** | **10** |
|
|
|
|
Voldoende = 6+. Bonus telt mee.
|
|
|
|
---
|
|
|
|
## Tijd-indicatie
|
|
|
|
| Onderdeel | Tijd |
|
|
|-----------|------|
|
|
| A — Webhook + verify | 30 min |
|
|
| B — Resend email | 25 min |
|
|
| C — Retry logic | 20 min |
|
|
| D — APIS.md | 45 min |
|
|
| **Totaal** | **~2 uur** |
|
|
|
|
---
|
|
|
|
## Veelvoorkomende valkuilen
|
|
|
|
| Probleem | Oplossing |
|
|
|----------|-----------|
|
|
| Signature verify faalt | Gebruik `req.text()` niet `req.json()` |
|
|
| Email komt niet aan | Check spam, check Resend logs |
|
|
| `stripe listen` toont 401 | `stripe login` opnieuw |
|
|
| Webhook secret klopt niet | Stripe CLI geeft een nieuwe secret per `stripe listen` sessie |
|
|
| Resend "domain not verified" | Gebruik `onboarding@resend.dev` voor demo |
|
|
| Headers reading errors | `await headers()` in Next 15+ |
|
|
|
|
---
|
|
|
|
## Tips
|
|
|
|
- **Test Stripe alleen in test mode** — productie key kan echte geld kosten
|
|
- **Stripe CLI is essentieel** voor lokaal testen
|
|
- **Test cards lijst:** https://docs.stripe.com/testing
|
|
- **Eindopdracht-tip:** kies één paid API (Stripe, Resend, Twilio) — laat zien dat je productie-flows snapt
|
|
- **Webhook idempotency** is een real-world musthave — bonus laat zien dat je productie-mindset hebt
|