fix: update lessons

This commit is contained in:
2026-06-03 16:58:25 +02:00
parent eb1ba2e28d
commit a852aa0d52
83 changed files with 16830 additions and 0 deletions

View File

@@ -0,0 +1 @@
,sharp-zealous-planck,claude,21.05.2026 11:21,file:///sessions/sharp-zealous-planck/.config/libreoffice/4;

View File

@@ -0,0 +1,302 @@
# Les 17 — Externe APIs in diepte
## Docenttekst (Klas A — 3 uur, fysiek, demo-driven)
**Les:** 17 van 18
**Onderwerp:** OAuth, webhooks, Stripe, Resend, retry patterns
**Duur:** 180 minuten
**Demo-app:** `stripe-demo` — subscription-flow met webhook en Resend mail
---
## VÓÓR DE LES (45 min)
1. Stripe account in test mode + product + price aangemaakt
2. Resend account + API key
3. Stripe CLI geïnstalleerd + `stripe login` gedaan
4. Demo-app `stripe-demo` werkend op laptop (backup)
5. ngrok / `stripe listen` getest
6. Browser tabs: Stripe Dashboard, Resend Dashboard, docs.stripe.com, resend.com/docs
---
## HET SCRIPT
### BLOK 1 — Welkom + Recap (15 min)
`[SLIDE 1]` `[SCHERM: slides]`
**Vertel:** "Welkom bij les 17. We gaan vandaag externe APIs uitkluizen op productie-niveau. OAuth, webhooks, paid services. De real-world stuff die je vorige les met PokéAPI niet zag."
`[SLIDE 2]`
**Vertel:** "Tot nu toe deden we externe APIs eenvoudig. PokéAPI in les 15 — geen key, GET request, klaar. Maar in productie kom je dingen tegen die je nog niet hebt gezien. Login met Google. Betalingen. Email versturen. Wat als de API een minuut down is?
Vandaag bouwen we een mini subscription-app met Stripe en Resend. Geen echt geld — Stripe heeft een test mode. Maar de patronen zijn productie-klaar."
`[SLIDE 3]`
**Vertel:** "Drie uur. 50 min theorie, vier demo's, lesopdracht en huiswerk."
---
### BLOK 2 — Theorie (40 min)
`[SLIDE 4 — Drie soorten API integratie]`
**Vertel:** "Externe APIs komen in drie smaken qua auth. Simple fetch — geen key, alles publiek. PokéAPI is daar het schoolvoorbeeld van.
Key in header — de meeste paid APIs. OpenAI, Anthropic, Tavily. Eén string in je env vars, mee in elke request.
OAuth flow — voor user-authenticatie. Google login, GitHub login. Complexer.
En een vierde dimensie: webhooks. Geen auth voor calls *naar* een service, maar wel signature verificatie van calls *van* een service. Daar focussen we vandaag op."
`[SLIDE 5 — OAuth basics]`
**Vertel:** "OAuth in zeven stappen. Klikt 'login met GitHub'. Redirect naar github.com. User logt in, geeft toestemming. GitHub redirect terug met een code. Jouw server exchanged die code voor een access token. Met dat token haal je user-info op. Cookie/sessie zetten. Klaar.
In Next.js — Auth.js is de standaard. Drie regels code, één env var, werkt. Niet vandaag bouwen — komt in huiswerk als bonus, ook in les 18 met Supabase Auth."
`[SLIDE 6 — Webhooks]`
**Vertel:** "Webhooks. Het tegenovergestelde van een API-call. Niet jij belt de service, de service belt jou.
Stel je hebt Stripe Checkout. User betaalt. Stripe verwerkt. Mogelijk gaat het mis — kaart geweigerd, fraud-check. Mogelijk duurt het seconden, of minuten bij 3D Secure.
Polling Stripe API elke seconde — duur, traag, lelijk. Webhook — Stripe stuurt een POST naar JOUW endpoint zodra er iets gebeurt. Jij update database, stuurt email, etc.
**Cruciaal:** signature verification. Zonder dat kan iedereen je webhook URL aanroepen met fake data. Een fraudeur stuurt fake 'betaling completed' event — jouw server geeft premium toegang aan iemand die niets heeft betaald. Altijd verifiëren."
---
### BLOK 3 — DEMO 1: Stripe Checkout (25 min)
`[SLIDE 7]` + `[SLIDE 8]` `[SCHERM: terminal + editor + stripe dashboard]`
```bash
cd ~/novi/novi-lessons/Les17-Externe-APIs
pnpm create next-app@latest stripe-demo --typescript --tailwind --app
cd stripe-demo
pnpm add stripe
```
`*[Stripe Dashboard]*` `[SCHERM: browser]`
**Vertel:** "Eerst Stripe. Test mode aan, rechtsboven. API keys ophalen. Belangrijk: `sk_test_` is je secret key — server-only. `pk_test_` is publishable, mag in client."
`*[Maak product + price in Stripe Dashboard]*`
`*[.env.local aanvullen]*`
`*[app/api/checkout/route.ts — code typen]*`
```typescript
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const { priceId, email } = await req.json();
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: priceId, quantity: 1 }],
customer_email: email,
success_url: `${process.env.APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.APP_URL}/`,
});
return Response.json({ url: session.url });
}
```
**Vertel:** "Mode subscription voor recurring. Mode payment voor eenmalig. Line items met price ID. Success en cancel URLs. Stripe geeft een URL terug — daar redirect je naartoe."
`*[Pricing page + button + test]*`
`*[Open browser, klik subscribe, Stripe Checkout opent]*`
**Vertel:** "Test card. 4242 4242 4242 4242. Elke datum future, willekeurige CVC. Klik subscribe."
`*[Betaling slaagt, redirect naar /success]*`
**Vertel:** "Werkt. Check Stripe Dashboard, payments tab — transactie staat erin. Geen echt geld. Test mode."
---
### BLOK 4 — DEMO 2: Webhook (25 min)
`[SLIDE 9]` `[SCHERM: terminal + editor]`
**Vertel:** "Maar — we hebben een probleem. Wat als user de tab sluit voor de success page laadt? Dan weten we niet of er betaald is. Webhook lost dit op."
`*[Stripe CLI lokaal]*`
```bash
stripe listen --forward-to localhost:3000/api/webhook/stripe
```
**Vertel:** "Stripe CLI doet een trick — registreert tijdelijk een webhook voor jouw lokale poort. Output geeft je een signing secret. Kopieer naar `.env.local`."
`*[Webhook handler schrijven]*`
```typescript
import { headers } from "next/headers";
export async function POST(req: Request) {
const body = await req.text(); // RAW body, niet json()
const sig = (await headers()).get("stripe-signature")!;
let event;
try {
event = stripe.webhooks.constructEvent(
body, sig, process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
return new Response("Bad signature", { status: 400 });
}
if (event.type === "checkout.session.completed") {
const session = event.data.object;
console.log("Payment:", session.customer_email);
}
return Response.json({ received: true });
}
```
**Vertel:** "Drie cruciale dingen. Eén — `req.text()`, niet `req.json()`. Stripe berekent signature over raw bytes. Twee — try-catch om `constructEvent`. Verkeerde signature = 400 terug. Drie — check `event.type` voor wat je wilt afhandelen."
`*[Doe test-betaling]*`
**Vertel:** "Webhook event komt binnen. Stripe CLI toont 'Event received'. Mijn server logt 'Payment: test@example.com'. Werkt."
💬 *Vraag: 'Wat als ik de URL niet weet? Of geen Stripe CLI heb?'*
**Antwoord:** "Voor lokale dev: Stripe CLI is verreweg de simpelste. Anders kun je ngrok gebruiken — geeft je een publieke URL voor je localhost. Of `localhost.run`. Productie: gewoon je Vercel URL, geen tooling nodig."
---
### BLOK 5 — Pauze (15 min)
`[SLIDE 10]`
---
### BLOK 6 — DEMO 3: Resend (25 min)
`[SLIDE 11]` `[SCHERM: editor + resend dashboard]`
**Vertel:** "Mail versturen. Resend is de moderne keuze — opgericht door ex-Vercel mensen, mooie DX, gratis tier 3000 emails per maand."
`*[Maak Resend account, kopieer API key]*`
```bash
pnpm add resend
```
```typescript
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY!);
// In webhook handler
if (event.type === "checkout.session.completed") {
const session = event.data.object;
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>`,
});
}
```
**Vertel:** "Voor demo: `onboarding@resend.dev` als from. Geen eigen domain nodig. Voor productie: registreer een domain in Resend Dashboard, verify DNS, gebruik dat als from."
`*[Test: doe een betaling, mail komt aan]*`
**Vertel:** "Mail aangekomen. Bekijk in Resend Dashboard — bevat ook delivery status, opens, clicks."
`*[Optioneel — react-email demo]*`
```bash
pnpm add react-email @react-email/components
```
`*[Maak emails/WelcomeEmail.tsx]*`
**Vertel:** "Voor mooiere mails — React-email componenten. Schrijf JSX, Resend rendert naar HTML met inline styles voor maximale email-client compatibility."
---
### BLOK 7 — DEMO 4: Retry + Rate limit (15 min)
`[SLIDE 12]` `[SCHERM: editor]`
**Vertel:** "Laatste pattern. Externe APIs falen. Soms transient — netwerk-issue, server hiccup. Soms permanent — wrong key, validation error. Retry-logic voor transient."
```typescript
async function retry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (err) {
if (i === attempts - 1) throw err;
await new Promise(r => setTimeout(r, 2 ** i * 1000));
}
}
throw new Error("unreachable");
}
```
**Vertel:** "Exponential backoff. Eerste retry na 1 seconde. Tweede na 2. Derde na 4. Voorkomt thundering herd op een herstellende service. Wrap je externe calls hierin."
**Vertel:** "Rate limit eigen endpoints — Upstash Ratelimit, gratis tier. Hou DDOS en abuse buiten. Vooral relevant voor je AI-endpoints — een gehackte user kan veel OpenAI-credits verbranden."
---
### BLOK 8 — Productie checklist (10 min)
`[SLIDE 13]`
**Vertel:** "Voor je live gaat. Vink af.
API keys per environment — Vercel scoping uit les 15. Webhooks ALTIJD signature verify. Idempotency check — zelfde event 2x is OK. Retry-logic voor transient. Rate limit eigen endpoints. Monitor met Sentry of Vercel Analytics.
Plus security: keys NOOIT in client, geen NEXT_PUBLIC_ voor secrets, HTTPS only, user-input validatie met Zod.
Tien minuten voor er aan denken, uren bug-fixen later. Voor je eindopdracht: check deze lijst."
---
### BLOK 9 — Lesopdracht + Huiswerk (10 min)
`[SLIDE 14]`
**Vertel:** "Lesopdracht — half uur. Stripe account, product, Next app, Checkout endpoint, test-betaling. Geen webhook nog.
Huiswerk — webhook met verify, Resend mail in webhook, retry-logic, en APIS.md. Vooral die laatste — reflectie op welke externe API je in je eindopdracht gaat gebruiken. Wat je geleerd hebt direct toepassen.
Beoordeling tien punten, voldoende zes. Bonus: OAuth via Auth.js, idempotency, rate limiting, refund-flow."
---
### BLOK 10 — Afsluiting (5 min)
`[SLIDE 15]`
**Vertel:** "Wat hebben we gezien. Drie soorten API auth. OAuth basics. Webhooks met signature verify. Stripe Checkout. Resend email. Retry en rate limit.
Volgende les — Les 18 — is de laatste. Supabase Auth en RLS. Multi-user apps met login. Per-user data isolation. We sluiten af met een combo van alles.
Vragen?"
---
## Veelvoorkomende fouten
| Fout | Oplossing |
|------|-----------|
| Webhook signature fails | `req.text()` ipv `req.json()` |
| `headers is not a function` | `await headers()` in Next 15+ |
| Stripe CLI 401 | `stripe login` opnieuw |
| Mail komt niet aan | Check spam + Resend dashboard logs |
| `STRIPE_WEBHOOK_SECRET` wrong | Stripe CLI geeft nieuwe secret per `stripe listen` |
| Resend domain error | Gebruik `onboarding@resend.dev` voor demo |
| Test card geweigerd | Test mode aan? Rechtsboven Stripe dashboard |

View File

@@ -0,0 +1,349 @@
# 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

View File

@@ -0,0 +1,156 @@
%PDF-1.4
%<25><><EFBFBD><EFBFBD> ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R /F3 4 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
>>
endobj
5 0 obj
<<
/Contents 13 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 12 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
6 0 obj
<<
/Contents 14 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 12 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
7 0 obj
<<
/Contents 15 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 12 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
8 0 obj
<<
/Contents 16 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 12 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
9 0 obj
<<
/Contents 17 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 12 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
10 0 obj
<<
/PageMode /UseNone /Pages 12 0 R /Type /Catalog
>>
endobj
11 0 obj
<<
/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260521112116+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260521112116+00'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (\(unspecified\)) /Title (Les 17 Huiswerk) /Trapped /False
>>
endobj
12 0 obj
<<
/Count 5 /Kids [ 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R ] /Type /Pages
>>
endobj
13 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1794
>>
stream
Gatm;968iG&AIa;lm4G_3eHlG!9VWGVeHdiGhOEDP=mMB"\Xe';C8AVEp7N2Je*Bp(#)GuGsl$HI(KdQI_0&Sn\_s,C]j0A%d.aI0Fm[n@$4'J?bu4&kUn&aE@kZC_GHA6+Gk&e:p6T+@RX85'*a2+@^H8P(bm?l^i?!t9LE[Uk!d4=BLT=k5R`R9;>9blU,YZnP/Ks=a86,=N&*+1C9S2mm/IY+=0TLiO")EK/ClQ"2fSK2T:[ap?+$t4[]ZV3rb?5<X]tS;\V?$cfgXBN9l$*bg4$@s[GBK@I1GkP0Rr&':%5!8M%io2ia:@Cd,&uY]\2'9[`<fgY4==O2?EZ)LH6V.5$4ff,<CYb?H.UF*\#mRM561/A#<)\,U&[-pL\IDap9'N$F7`K(__SB0lq\o9G*t$!Zm?+UgN5KGtAcG?5M"^bY)V7UHr#u;)G&`mpZ1aE)I+]BH?%p'4qXhkW?ge&REm'4H9BeG:jG)BJ]=PCI/Ji:%4]$_^_k69:YI"idLP6)UN.op7HbW?nmKU1FtL4$'/5@aYN"Gqt-k,rW[KnQRs%VHq9Sq#\/`J/[te?-;pk-BaApF43$f!$?RQ03g($bi[,r0bZTJ#TBu\9mM:8&O1)=T''psi$_5eVXhWofir.srfdn4>&k(?3AM?]Q$[f\/MY[L#&a`nckCL(hc>N'LD;7oQR!oFSeGgU'$3li<`#\+CddcANCRZr7]X0=&/>&As?pM84(_tg2m@f,[',0<j;cVAph2:H8ja+C1(MRC<@b7d=JR2X^:cF+;r@,3.b5s,s[cQOu4]MSGeF5A7hsD2(s4]#.5NF-?<Rj:ocqjHncbD*)i6"i@ni)?[Y.b/?iCJfdiAej"Ah_Dh@Q5p#_CrE+2*\fDiP+=S!um=Ol)=Z^#d-rNL"+fYhF5a+(+U$)l(YD,r`KV3O`+MSG3"O=Ppb1D%TP((+h;We_A#ip7*F@45BGm5!sm>T8&oV(HgsrAL`M0[>Gsp3n.F\r3\9L0BkM&_C\[f/b357'P@Irj65)`Vb`IBbA4MgTF`cP=2)0T8PFan/9GVmAN1WXQ,j\)\o783;>g&6m.U."Gj7LIRgiq]k$orZ7mIp&"_KB)G><?'C9a//AKecA<f[eSnQaOAph%\E=,qNl%+tp%u(U$lJDIGb+nEa4YVJT*P].FLEMT4jCgY:_rg,,q,B*.O[?.2aEXJf0F#.=Z(Qcj-UXAeJUB!o<$XA_c`*rf+UU9W2C@Jid$YcK/5d8!5?8/NM8<7hmmEU8HeL8>aq0pQ>pj(;ETWg)LA8Na/t+tph7f%[@FnKQ19J_#0a/?dkJDnmdBd(jX+6k%tYn</)?lF",C+V*)P@-"QF].ri'BH<++5O6/mRkSou6mi*V29oJh7t`SKW43ao0tBWfqI;^<:@PmH!rKYE/V)oXmn+)Z`u\WZQHR.rYd#c:q5R-C*@;n0S,DD1Ho]*\[`C25^-$nH)j&SE5l'$=h5:k_G'/XAVnm0u>eS$Y<)g>@>!T?6^BGZ0J_gq].Hqgq&^)R4Ogk9FE).`Ha6))XIFQ6HNg.,75nqD):eC=Z@`C9hju16S@>sbkEuQ&b3W?i#*[eiLDh45t<1(1ge](9hdf;ji/f1uDpcsJ6_uARg(nQ[jll*JdU8Po;D^Ye?#iWYP7Fe-@qsa`]l0e2VJ'>,2%)^OYi^Q%jkN%),G'NriE-,/F]PZB[Nk8Q;PVAT_<:rGWQa=+eUb"+K<R\YrPg]/FS"C3jI,DH__8WnpY.I1Zo2Hg]'1$i64pS?+DQCE4DR:P_#HVg54o~>endstream
endobj
14 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1360
>>
stream
GauHJ9lJcG&A@C29QG02KHVe%UqG>IgNCl9`?n<l>:^ni:hA?]@nVhM-Ok\*pDm*Q[bEjsmNIm)=VU\o]p[gf),Ru5qb<@VDoQaB`di%G:Orfr@/L$pA+.tn>]M+l+MO_%aFT"nompT7AIqBV6QS#0<"Z58#gJ1VTa$PCP[Xt,"4L<m"/7&@=ocr$OBVXfhC"J-1bD6@Abc6#L7bX4Kg[icOCnG[0V#rt;&uYi#\APo@(_0/T,3!L_n1;.b%.+,$i%m\-CJ(o3*5VGr8`!'VPQ-`pqtmT7D;robT=Uq.u[h8[^P[Z*t[C0[>Y($P^n/=Xjcjt'K7o;ANWVl)#+ln7s&L(,^I:,KTbg9h4#rY_C,d<HE=a1T!(9=To03fE'/fM-^:XEh2O!KTkUoR1Xq+H&h9j6*msAF`7kDi1e\\>A"!OZ<oL0sGsL[e*J854Tf*DhNtJ5]en_htUDmEo7lOrops="teo0&Sr[-LWF/`/FFU"A-q%1+OVF3@7Z5'm2KUd#gS0J<;FkkF,.PE>8_VE'C;j?bSdir/9@:m7,dq8+=\"BQbLWDIFfacrJ/^fnuYY+Z:MaAj)1_^\Kr`Mpi"#2$jYp@Xf$T1'E%[jkG$qGqMOXI"WM952,p%[hMEqK0Ro_*#Vrqq6;cTu[&6\[$]Jl)3(Oi!s!,@eGJj`1*X,qrV;M,!RM/haBJd@`2&6b"n\CK&sN#Te>O#93iLSFHVFXn#+KGsIAZI@OKB$b+&`KVCcI&qS`/=h"Q7VDH/J"uTQtjpEY#+u=kU:!?@O@M[^@@1K'-K0Dq5P>KAep;0qI@ZoDQ',34%T)(^>,#lsUXt:]@]gW%?AC-d!g*k+E-'3KF"\[]H8Y#E^SQNoW)E\NLqRG(E$uiH/6?$3*mMLD9?Cs]*hHaC!Dpn"1H#tSiINYkY[-52CPT^W*CYl.;Jg@eH*+NZ>@L-uId%=Bj,*bmR)(c8O2KScXb*4FKO)]DTM;$sW3QAp4>)[qk"@4J!os,84OhT6qalb&K5#FN,H7LlEKcuA%L'd'q1d!n'_b'1smcm"%*(tJ*3?,b'?7V"_/b:M34Q:TpdQ96&@X>0$Io+\P/(gcVDC0MXc0tkPl4_)0pYD]@_<'A7=`k!h-/97)_'gm02VbRID0+1l6b6im]%:dt^]m(F,k$aNm<X`t%F+hVSNSfF9QsGS6qjSn,^kCfO(l%6$gR1%E4-/RWSYBtZH4RlEG&>X%(k,]#%,-8VN%]bdWF<2<OahR3_aTI=3Vr/SmeMB8E9@(UhsrHP-eb;P+-i98Vo;-7:FkTY-8`#fs36SpN\KmH;8I4&UdF?$VLA)9&J4m$>c;H9rg@D`&@V2hFDd@pFhc.oV;~>endstream
endobj
15 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2090
>>
stream
Gb"/'D/\/e&H8h>EUinABiBb^hP`7;eugVDXR4`e>;<oY/'CVf6nb\g-Ok\*pDk]>QJEfQ$S4_t3C,3galf8U*KL`KJDq*gJ"m_9fbem>0OPm?a1r\S_"\Y'`n"d\E0'mV$cEh1%QKQc<\GjrX:/?pGQA<l#jN)^45^B6KEq^"SEKdnLI/lJ6]2!84mEC[Lg[A5]W^JP`8_hC42gg)?upe7:FLMinJZRAZi3BI*5S):%'&\+b$<hXE=0TBJ,F]t0Coi[7e8`<i30/BdCb&;k<;!kHMk-$j0Fh(GV7<ROtT_$<SK2kL%.OlX+Tln/ob^mYN*B\,p;*3bIG3H`EA2t2EZi&&F9nR4'^8Nal<h/T*f$Xa=+`C`@W$17bEaaG`Z+a-2"%<,o64]V2jcl\7e>lK_Z\X>249pA/Q=M0Ys-.18Y&l"YLX.S(2!WJ^tAU_=fmo(k0Y/?W$toNL._\.i&G?0I3.0-B(^\OYsUi;5p0KpU`(Xn`]92LhQ(KPI#cn(2tmV+g*D%Tl]q/P8D+$A_qh'i-QA,+T-#&LD-!X=L`/Amq!rS)-k8D/5OG=%H@R<_fJs3Or.tQZJARu*nE+<D6E2V;=oB#ah66uU$Dch*oSu/e8frOhM&t8=H9%hilYf?W^j!TrN8Sr@j9+K*1I)\Fd[u!D+K\45(^6$IPYZVTX@1FU2M_&nV-1S9ik_Ej\&U/=N*O,>hf'$RapGXeR:h<P=KODNdrpG[>&\#[\IoQa;-4XZPdp/Q,K.r5s)E$<tg(7pS0rCDk;H+qY'.5n"Y@Tc@F"&S0W@o-@HbUYnQ?@pVSf4CtpjBi,pj$*%Z#h$BoIa\^H*="(P1G2]k`5NJ4ONkfVL?B:L\mBg)uJio5'B(DU1@ZYco)\B%Sce-.ONEd%gDl4W6iNeWG^XK7l5JHO!S70>Rg/rDcSAZ71h]hl-agH@_%)ab*gp2TsX9U>',fX_c\h-E"_YQIVicoZl*R*?7Hli8)!Gg'_6W@eu"%LrtV&C"d.Q7jad,/#t^8<g$17,j=gfXmW=(D+V!Jnp/#72WM-'<,/A8a=C#mRD2EU0'42!da8.@.:UdBtR,J5L63P=NodkjOXT`-I32-4*Q<<0(Eab1mh:W#:@#n=llV65&-*^4ZKlEYRU1>W*E(nAQ0hf&%]6f1d6VJl;f")[5J+@YL3Q)p?1eH#2@kh`TR3=iA3J%Za1"ao=TTP$"93t1/U\0p3.=dZ$2\com1K2fQu&=DHkHMT(B1WN6PPbGE-%DgP2I?;ma$rZ0Tk01l$:';Z1aMK5ihK%EXSJD<D;2776Zi[<)f$)H/0AiIq%+XX$cQ\-uA0`u!7?[t(f-n<#p'0CiS,.?m8(/XOFM=%^#^YQp]]8KqRmX"_AShF[mh1qgY+=2)Bn<\3*"<@NeFh+^kr`jfs%Cabj6nEr$Bmg?3X%q]61f0'E:8=ihS)i$K1PPh6Sb4iLW%VD2=42NVqs-NTV.eS;gc_Z(j>cLDPUMl@kbf/$E2?&XQHlTPW/+D,YFD[QCJK5TDa-fj:1(9+1#sRss#X2al.knR*Fl.dH#iaj4lC>sL>4@7-AJW,P15dUr"t3b2PPaCXafn;)q&X7hR*mM<*[Z?A%0qZe"S*6;5Qp=3U7(3.12&AYWflV@.T%tTI5>/69D's)HKI%8j`?iCQ`nqM7Od_mmbfi,<]cmhmr^$GO%s2pT`a1.EMGF[$rJB=N1T8II+pT2i29)+E)$0O].tAqV=r?&b/CU,TN7;tR9JDoE;^5$>X:ee(!Vl7/;em#?%K+j"2r5>3S"hrUusHd`2_rgIK>8ZUY=>lGR[j6%q[;"\7j^Z4bVEq2(+=[@P["+&GP#ff#7:i4\m>03):m=:N^;rE#$88@+W"$Y1bS4eLf^qQ"G$,QCiJ/D('N!WcOs'2ba<IL5"=^V6%[V.e,QNL7LBRa`akCFh&!1akfkU4ejrXGF^odI8go+0c:Ie=Wt[r*_mA/keE$ueTGeoa+LX6o(OH47=D?.DjJs=J_\a<>'J`39GXp!'jXjKLmh#nFc3TkB9o#/oSqft:5`\B\%d:kBq3.V(a_46I]6k?8Si06Ef9&_lX)FVCbl~>endstream
endobj
16 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 497
>>
stream
GauI192FS%'SZ;W'kbWB_Y?0@ATf'pU/JrtA7ie-Ui$I60pAD%r-R4pTTfd!/1.g<Z1#6\\L.M$(A2=S"Fd7.4sQ.S\c>;KOj7p77fOYZZnPC/`KnQ$!))=rLSflB.*ZiX&q]5"=X,(A0l%F1&aG;uK>q>>(_N._gfOkp8SP7"l=6]F#d#CO=skEX[sW@OQS?+L$^egA@c7K-9tdUIP&XAh+^O*cipuOPkItnPId_]=5@*=?q[U)%(egesPI<'tcHU@geunR>936=%O?OdMh"2NLb?-J:F0ho3F"II^q5S8'?1U&>?=cQ.B94@3lS-gO;A3Y['pg)&E6\:G_,MKampcsuTtJp23V/s*MjQup*-/rCHm[],qk+I;ad=*e0k<sO&Qp%F_QZ6:Vd8h\0@4uOG9P/4.DqM[GNW"dX$:G=I/QQ>hf:TV0tEb0YiYdaGd[7Z\T=pBA"D.PA7ZL)Ucb\-r9R_(TO$ZiC\MMFqh=5K#9(rY~>endstream
endobj
17 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2069
>>
stream
Gb"/'939k-']/^ggeHt-m3!t5Z=RXq6T_Yd:=$;$P]!e*j!]b:?e@-%rqj7s.o+.f37(YsLi$;+O&,L!$7:48oc(gLDhi<0ScWIWS-$*T%Dtt\2Df:[_Aq^l*/Gn7b@<e"9E.:A1D8UUEBNA\&>i*RCP+2e&],3sE1Q;m(NKi2<sAif3i4ED+G+W(3-WqX`*j'WgOqYK\%058:MB8\i\])WR.V8Pi=+EgePW_j@T)W7(re9)p[coqHq\1Y#j,h>_)IY&@'3nUQj]hT8#Jcf(.q?[Dg<k.1lGq>?G^X^_cN:.O1oIN@U#_A:B-M/!9O/]L;d'Qf/;A;r[^`ji&sD6&_*,0_\u*P;\2Tc%-"sV)\sVB/DIEML^h?qIG9`M#'5W8g7gOG6o3"5e9+lCKXZ"(UDq)i.O`iC\fhUIGoSm!(9QbA!ji%1$J=3*#0"&np!W14R2qX00NV.?ps9?#/FM$OBc\+4UG==tWV?De]f)J)H6"1JqrmF87k"$I'0q\_-+KoIm-ifF&6cuaH0s"'.f(2pGNmk@E9a.UY0fK4EdgU>$&f(t1=)[+m;;LdO=`G`g"CuN5H)?_2NC%:%[GM3[F8la#3Q;=9U6a]BrVGDKo7Ao1/OYq:=f463HtJ>?.S%P<j,a#4$K_R%IJHB?75)7e(l?4>F1nDSKcd"j(U*C]B!]N/##2b@.7Gkf+N$a$<X+c')X;5R2kC$G[3E,9#3g\\]Q#;#\'dK0@_%K6]`'uVgJR$-\6g(!s85%&<&@3?)'EY1>Au'V96\L_RiV,W38lA=W/Uo'q\$E7LK^(5G8@'.?e54mK8)+#oM5V^u17Uj!mdpZ'<W=MD>HJ;,#.=VGXq[%LcfdUs^L#<#2aUT\'?uoct[4pAi$U*!l$30Z&2Rpebnl\Q&CNm/YpbGX^j*DZ-B&gn;+]"SsAdnG@a?"Tp4aJoDJPP;Jci5/q3iA"!-p#:-YC0J(s4!](o6Z4.s4Gls%Q]n*L,@6N&I<4p&_+,:.Vdqn+f/.p!\9K(=Lf')FSAmIa]3c4.eq7AP0VV6Qip79pqm$VXA[qS_UBHV#d_DFR9g0#;8q:XnJCBtGCceNdQ-QH"8h<B[(O.>44olrWuDY\[[ZRc:sFI1b/#Q71/)P7P6qH/cepe*9gMI3:=MV#c2ZAFOH<66E\$,KjNf78`2S!6#R@*=Z\&l/)_AXW@(^Q<eBB'-9qH,])8.UVsC657G5_K]61Qu?-$r*g.M#020.*`kV&jh=?=rB`@P,uM6*IpC'/M.M6upN;K%k'fLX,d53B]iJ>FF$H'\1;A=c[j`\L[=1XEB8cW'F7)HppWjlQAX39?m1Q/pg/Fm&$7hR@=C=a??6@Gqbb^kQ*&s]JoqBOn@-39uh3gq=<](,QI*3Mhl,+E=IH0%_nsk>c?1nQ8[Q$gEmFS>nT_5,5gE&1!<c?*l4B0tfPjsd_*YYXa$\WEsQSQB:hOD0GM7l&7c2+[.'`Nn9(^g:XlUG]pMK"mVW^K;@<pPi,%4Q)i49mR9<>O2k\r_X6@&,DO@2pn-/<q<b`U7V=%3`0<&b]TIN]fkC2jhc:P;t*NQHO5E*+72+fuXBA\N7"[R'^KV5oOBu`9]5tnFiqnc%3<CCT-X=cDt7^;s!o(!E/SiCA+N=L]%JX(DoJl?l"&8F+nI2Vk=*>'eVZeKYt*:]m+,4qD!TFh3ih6&)Dq>Fi`.l5TS]U\1q<KV(Sd45E7"88Uq7u'P@42'e6r#;tJOED:^!B[fi(6-b3:(brGp-#N=o9m?:%;4aTW9ZWGZH5/=RNM&M7rCUQl09e!jca4FoZU&Ho"3<PYdpK*Fn0=1n$l@QNl0&sAFUk3q.TDCjjCP0R=.qblQIS3\V]oBI`<b9+mF9."EXRC9BI@\AXN5,%\lOphuV!Bi(c,rB-;Qg,O2%3`-KF!s>IB&L>%n6G7Lm%e;d9uSZP-F69Dm50q'0T7`Wg_:l=Oj>O8u1aO^7UoP[:td!nXj[2^nSKo=P>hbZ;#WH7K$JXmqIO;b2pX+eaZS",;YSa>0;55!nCi7NpDMkUEuhaj-(S[$<qP]UN?RtInk!aqu~>endstream
endobj
xref
0 18
0000000000 65535 f
0000000061 00000 n
0000000112 00000 n
0000000219 00000 n
0000000331 00000 n
0000000436 00000 n
0000000641 00000 n
0000000846 00000 n
0000001051 00000 n
0000001256 00000 n
0000001461 00000 n
0000001531 00000 n
0000001824 00000 n
0000001908 00000 n
0000003794 00000 n
0000005246 00000 n
0000007428 00000 n
0000008016 00000 n
trailer
<<
/ID
[<b76a3b1dc983906e6c703e8da1e27798><b76a3b1dc983906e6c703e8da1e27798>]
% ReportLab generated PDF document -- digest (opensource)
/Info 11 0 R
/Root 10 0 R
/Size 18
>>
startxref
10177
%%EOF

View File

@@ -0,0 +1,204 @@
# Les 17 — Lesopdracht
## Stripe Checkout setup + eerste test-betaling
**Duur:** 30 min in-class
---
## Doel
Werkende Stripe Checkout in een Next.js app — pricing page, checkout session, en een succesvolle test-betaling.
---
## Stap 1 — Stripe account
1. Maak gratis account op https://stripe.com (skip business questions met "test mode")
2. Dashboard → Test mode (toggle rechtsboven)
3. Developers → API keys → kopieer:
- `Publishable key` (`pk_test_...`)
- `Secret key` (`sk_test_...`)
---
## Stap 2 — Product + Price aanmaken
In Stripe Dashboard:
1. Products → Add product
2. Naam: "Premium plan"
3. Price: € 9.99 / maand, recurring (subscription)
4. Save → kopieer `price_xxxxx` van de price die je net maakte
---
## Stap 3 — Next.js project
```bash
pnpm create next-app@latest stripe-demo \
--typescript --tailwind --app --no-src-dir --import-alias "@/*"
cd stripe-demo
pnpm add stripe
```
`.env.local`:
```
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PRICE_ID=price_...
APP_URL=http://localhost:3000
```
---
## Stap 4 — Pricing page
`app/page.tsx`:
```tsx
"use client";
export default function Pricing() {
async function subscribe() {
const res = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_ID,
email: "test@example.com",
}),
});
const { url } = await res.json();
window.location.href = url;
}
return (
<main className="max-w-md mx-auto p-12 text-center">
<h1 className="text-4xl font-bold mb-4">Premium Plan</h1>
<p className="text-2xl mb-2"> 9.99 / maand</p>
<ul className="text-left my-6 space-y-2">
<li> Onbeperkte projecten</li>
<li> Priority support</li>
<li> Advanced analytics</li>
</ul>
<button
onClick={subscribe}
className="bg-blue-600 text-white px-6 py-3 rounded-lg font-semibold"
>
Subscribe nu
</button>
</main>
);
}
```
---
## Stap 5 — Checkout endpoint
`app/api/checkout/route.ts`:
```typescript
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const { priceId, email } = await req.json();
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: priceId, quantity: 1 }],
customer_email: email,
success_url: `${process.env.APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.APP_URL}/`,
});
return Response.json({ url: session.url });
}
```
---
## Stap 6 — Success page
`app/success/page.tsx`:
```tsx
export default function Success() {
return (
<main className="max-w-md mx-auto p-12 text-center">
<h1 className="text-4xl font-bold mb-4 text-green-600"> Betaling geslaagd!</h1>
<p>Bedankt voor je aanmelding. Je krijgt zo een welkomstmail.</p>
</main>
);
}
```
---
## Stap 7 — Test
```bash
pnpm dev
```
Open http://localhost:3000:
1. Klik "Subscribe nu"
2. Stripe Checkout opent
3. Vul test-card in:
- Card: `4242 4242 4242 4242`
- Expiry: `12/34`
- CVC: `123`
- Postcode: willekeurig
4. Klik Subscribe
5. Redirect naar `/success` — werkt!
6. Check Stripe Dashboard → Payments — sessie zichtbaar
---
## Eisen
- [ ] Stripe account in test mode
- [ ] Product + price aangemaakt
- [ ] Pricing page werkt
- [ ] Checkout endpoint werkt
- [ ] Test-betaling slaagt met 4242 card
- [ ] Success page laadt na betaling
- [ ] In Stripe Dashboard zie je de transactie
---
## Tijdsindeling (30 min)
| Stap | Tijd |
|------|------|
| 1-2 — Stripe setup | 8 min |
| 3 — Project setup | 3 min |
| 4-5 — Pages + endpoint | 10 min |
| 6 — Success page | 2 min |
| 7 — Test | 7 min |
---
## Veelvoorkomende problemen
| Symptoom | Oplossing |
|----------|-----------|
| `Stripe is not defined` | `pnpm add stripe` opnieuw |
| `Invalid API key` | Check `sk_test_` (niet pk) in `.env.local` |
| `priceId required` | Check NEXT_PUBLIC_ prefix correct |
| 4242 card geweigerd | Check je staat in test mode (rechtsboven dashboard) |
| Success URL 404 | App URL klopt? `http://localhost:3000` (geen trailing slash) |
---
## Klaar? Verder met huiswerk
Het huiswerk voegt toe:
- Webhook handler met signature verify
- Resend welkomstmail bij betaling
- Retry logic voor externe calls
- `APIS.md` met je eindopdracht-toepassing
Zie `Les17-Huiswerk.md`.

View File

@@ -0,0 +1,137 @@
%PDF-1.4
%<25><><EFBFBD><EFBFBD> ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R /F3 4 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
>>
endobj
5 0 obj
<<
/Contents 12 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 11 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
6 0 obj
<<
/Contents 13 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 11 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
7 0 obj
<<
/Contents 14 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 11 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
8 0 obj
<<
/Contents 15 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 11 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
9 0 obj
<<
/PageMode /UseNone /Pages 11 0 R /Type /Catalog
>>
endobj
10 0 obj
<<
/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260521112116+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260521112116+00'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (\(unspecified\)) /Title (Les 17 Lesopdracht) /Trapped /False
>>
endobj
11 0 obj
<<
/Count 4 /Kids [ 5 0 R 6 0 R 7 0 R 8 0 R ] /Type /Pages
>>
endobj
12 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2111
>>
stream
Gb"/'l#5<7'`Y>Nih8VgoJ"0M#8NoLdPTd3AXT$tm03\I'UaqDbE='kC&?fV;$SO3Q]n9A<84XqlQ,'Tr>nN7'YgaKNu%e78$Mr`!TPm1!mDrCN"09GjBOZtq`Jg\+@8s<EZh(&+"5F*'Ki#_+V,G])A!dri(?J7$mfj4(Bc:>6G]"T%Kd@=1ls=$aZNr,=2kX-F,m8+iq\&55/+F#C@adO\(77@bn#M:&(WdQU^EGbjolfb(9hrRf;A`G4Zc_V4O(/75$!ApV\P_B*l.3[@(.\"AYgaC:0jkFb82Y1?GPc3(qnG1HLbA0&g"X#Wr,4a!nrQn/>!W=&Nu)uB5%LM-gLl[CF@?hXg,TJ,R?PalZ)6d4$J[/3,[Y31JK%/6:a*/F\?WVHmuQWq[bid^mH7oo9^S7l[0lX(4q[g=P37$b"POroU6Gk<G$!EPt%m]fV)tJK)]]fp7Z76LX+DcV'\l.0$ng&E"OsH,gOEiq9B/$'S5WJc3$BZoq_8OLM\]>bg^=`9u6UBC8dCeXg7uo],'N@O"nbX@gGJ`KbuIKR-AN&%GU>KLT^daJ]6B"l(JrhS-=KP#gbt3K<UEr)S&!ZY6I<f's+8>ENA;bKUca$;"@1Xb]3l6;\dWbAHep1Q8uSZV$[VmW)Qn/c;YN)Zh41@=@!CIjr98EIKpP^?6Dst$]!<Xh6`i'['lMEP3^-J'[pY"]%-^ZW<G#RS^DN5$E9G6lVfXt=Y[L+<JZabZ`pEF[OV/lDH^%q:-?UQGKXR&JJ/Rb6"&?jdUf7QR&,EpM9>CHV@RG\c"_S+-!Xn]XKAqui%rj@C8O#j%4f3E!@2(SXrfR,K<Ye32o(3'7c9!lr(J8f6PZ.<$3Ml_+AQOZ:1'HLO<Aqg7Q#\jSrg!h`.`,f.('l,JKA>oRR:=1`8alZ^;i2K+%\!1e*PHlTit?Q%hG.DgS+G@[@K$GiU]]5S`_Jn>k*N$1mA^;%-,l*3[%pqGZs+sH<`MH6Gk]#LD>Dk[CBAU$5*f"V1D/Ha-oqE:4A#H9I2Q8Zp(t`D1Dm1:=bN/c`3A-ZO59_#8cu&IB1H(,t*%8OBl2:@UpP$UN?)H@`qXdH<L(_b%2QnnhNk$26J.t!&CAr^`7hKOBkX+U#REU[0I8Q[SjWLlXMuo[*ZIS3_W/5JuC2HF^2Ag1cbHG?fQ9W@`R!W$YGhg\!niU*.cZ[fpGn,5/r,U,";[HpV^Lmb%r:[JuI(kZ_M08Vq/q4Y]6H$H(-YBP9-cEJ_L]\9kMgI_D!c#PUe#VBUtUgHS`O-BjQ;_jOZs<G<4>d5*qH[+0WaMWM1@OCRhe7N8cg$Y*&K:6mP),LjS2;A.m;[oSk9Q+=47mi&71TG403(h('`J)+F.7(*LeoXQP;Uf`FpXb5!TDZXl-i1&Ts=M=H<d=J!7:d2]j%HOPu2"s3AkH2K9EVe9.39OR>tN6ikQA4TIn>?,iqhQZOW.aPCnQ/:fiU\H8rnFM3K2HpmNeS.6lLBfM7hs:#^QHsNZ#N^?7)0Ch,^V#!NS3r"nOhdgk-Ug3J7Im@L8D]r*Meo];S]NC0S&OYFNoYa[`4\.YIkm&V*u7K96o9HORG]-_0&OMTO$if6OPg4Ts!Wa?47I&gW5WS`J38\:B0Vp92^,d<P'W,7*IZ9D@2ftZPYPP#-=7P-qDn6SYdjSJ=SsGU<.3'CgLE,?VE^Pk2&Cj`:;*9)rM3[MSq="3_q!R'WkQ`gle7*PdC[Do`WMta),KS\Bht7ZCG3-gD-sMjfqg@iW#fWQlr>.$/e.Y7ZKouO"2mn:XqTrVhAm`1]dUo3FMb_qm:7b%2(6T_1aE,OHd1%IUK3^NKsX$?CMQ#gH=L<c7>68r?8QsK.(W&1%)+uGhQ0kOjpQtQ'ibT=>dFGPI"+j7^%lEPhLgYgA2"j"hfDf;=B"HN1eYR.;V\SC!S:\6GPX;EUV=+=ou3-/f\6Z)D[C2@UIdE@:.0BO+R7f'7oXe+Ot;WVS$0Ma-7K30>rV\<h'^82alrM-\3E11h`K[:NW7Z2k=p/P+GAMPP=PM"O2f(uD\!BP`i[JDn\O)]pu'tt-GS@cY7K1>iGe_bq!cP8Wq)_Jm2AOEXK6RJV/ul;i;<g?L&Sd~>endstream
endobj
13 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1207
>>
stream
GasaoD/Yn7&H9tY)#7*.ou=VeZ7inu\i*9*5"/&GG#.kK;FL"8VT%HpDAktCml+V_,`TYYb:LQT1XF?/4#.%1(U&=`28FtbN'D=?!bP*`&gbcIiG@Y)pJ<93AH:p5k$%k"rTXL\Xd;AI:g%3e(oG\o$.<cL`YoC&V`!(f4'95f31s;G)[U2&TT&^nhFAjpV3VrHAY6t$r(KQ*U2&<mc2F!EaFcF@C/KD9N!DCofc#l'0)q?TT@p=sU/dL*n>4?7ZmoK:KSK5bhqf/<>a0+,+5IHoA=Ku"b@?ofL"WLV<tAXFM8Dm`)#rA+5^_g^D&.EL6=5GUE>jGm@JroC4b,!/OO_RJmk[]:Gd6J(:W5'&<)UQd'*7NfXY''r7`M7gfn=(P,]^"Th\$oV,-SE%JfDucW7GM$cN_a*qg@k]&i^^P,'[3;*iHK[Hc]G6en0b$f!.d0TdA@p\(g-64RuNZXfLa@[t"=TdSDH&Kr0u'YAKf'0O4Cg$.h+eWS8bHI6kS#o@&$s?e+oe`NT&7@)+@.pC[D3$o]:J.HGI_Vmil:Dr$KpUp2>MijBacj41HImq#A>JIW]XIH%X^[GIOjes:R/YUZk5[>.^)P>(^&k26nuh,1&d"bu4gI@EK=r@2gu/neqJ:+_+L6;ZK*V$Lp3mksFH=I6'$L$A<H3j)2)\!q:2X=/J#FjJ]]R]pHS4"8Dk%&g%Gb=:7'6'T;E!(O^f56BOuXY?t9egTqi0.#qef')Pb-Hf%a\7N0E8m#u[W/S(T+HH9Fr"pIZZ3F:4C>;j7!+Bl>7NG=1]d][<^2a/\8lAq'[ga$:Pbgf?;n%:d&Di*q0m%!6RNIO/i?fU9.i3WW[cQ`G+>TL+X-IL`!S4DYAW&K$]"4EW7^!'V3K4s%CbPc7>MQuE^S[>6=Hkf\.ph)YO+qn`!4`I549.9=Cc<LeRllU6;4pR3i5OBaZg4YF+3l73f@/F9ajo);e`Y'=g%FFd!*AKM"</5c3SZZS'A@9eMXh;P1L+i2LJbK$g,PWX5EI^F3j@H<GT]Q)[#i:`2Ma[(Zl<\[ddSWdIt2C-^ohN=8HHRA=^%e=!)Ws!Hu3'&]/X),GF2o<==Fnb\Q]\!i=>qa5&+G\"4HUKDa?aiqR=grkH<V&.EmiJVuc-E6p/$5Q6i)\/3Q_ekhFjIp5&(l9b5F2n_U;V(tYI6)a<&X`4JMQ*PDCP3#X*=~>endstream
endobj
14 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2160
>>
stream
Gb"/(>>s<8'Roe[3)067dR6X3CrpeQ:.f339dW\[PA-q:[`0g5#Hqu\08)+YpE',?/F,'^DI:9*CK^MZ7GkYs0VJ3cb.kaoi;3gss1j/)R%+-i7)7k8lqOR&Mf>"Ui;g+t[65#<Zcs:sF"7I8-*G9O#)Z$IU]@)*;9DhQELi=f6Di`&\]'(iQV&QS.-A%^]sliF0'=Wd&BAq,]Ba82PYoaODQOqZ=oBH[c="%W/ZLB.AWCk?Z@K'$q]A;s;_lsME1/$a]Iu#\S/-(^\V+%ZQ`osBbR6RdfD7g(8\38gnI-`d@Ii3plJW+'#ks"bNW3&BJGS7O8S=IK66H!o0_W?N@Jte,D89C_.k@SC[S8(9a',BF2EF@]_A+[S/FJVb@lkH/>S9Y_lA]d`cOtn&QhO"?AklaAA,(U(`XGg.jP`5IJJR!PeXTZkAfs>BS'R59n1!ZEdM@YI;?Dc">"!Va.TnEBc0/:O=Jo3A/+[%`s7QtX5:(kdU2#SX[Z+/$(#q2+`3kl*b&)3Gqa+EHk.B4d^0BTDK\sfdS2e_[mbYf&(Oc]d7>9Pr*LnG!!d$OtPOK@&<cZ&c2F0s>OqWcu3X/K/Xgh5K![Ecq9)?C;;H?*9N^]71e1Bs@g.6>m'ials<&5o+g+fI0;T<4r+&g*VgoRTmR7F;LXTn.u6?P5A(/h-gA(lL]:6o=Cl>3,aY4_Y:RdT3.B%Z%,)b3sA_]p48*9lUCddQ;3Ko7e0$5aR^QspEFOp*tibF9kdY>,HWN5JLS9SN`u8E.:4C*@s,Z+d:_O1*If$$;aP*FFoujD]4F2]_AS*pub"Dt\YWioo'W(kK[T<bf6Y(.O]>h+AR`WO0dnYMoOWQ>o`S8IPk$j2o\LVU'JE:nKC`?5u+\lN,rQo73X%WrOV"Y9D0-5`]M15VN$hJfD)L^\#DFZhQQVQm_FOf!&H0+j>)I/a+/pATVeY.Yj1[%Ob4>cUj44^HgrlkjR1q(JcnT!#E[Mn)+[/O7N@FNFSQm=HA#A?onG=6\>u)Z(?](?K=3;HgQc_c6qdJ5;'h4C'QJ2rYPWZ4X,PW]EJh3H''Y"2D&4H;gETf`0%tdaBF[Q)&ULjQPh"Fq;lakK!,XoVXG&QhJCf-^8lZC%;4R'n,B]'qe#m,";s6AO7qI*8$?k1*0;cmin@:Q(AMmBU\1pJ0Q!:MS+?jVAro_,F-Yu8l33S.Q<B//cVnA4XuUb:V,F_;"E48?EcX+R2a`F,86Y,.cc(<r`SK?<EHeJC5/G/t6j)0Wf`*Gis0(Ug,dPY"]mB)b9$=MuYJ;Hb-aal",Gq'A2f1]r7q`,s4'<N*kPYOJ`p!P:qVi+OjXDISNo@>nd[fdm-jfh81s(XrRo9*=V"5;GU@8T2<Fn2As#d>EKp+4-]S,GWIch5BfM<q3pMF-IJDCaJpi5eR@8"'Uhg&Qe#,RI&b!TN]%&CnR(^AMd2M\D%4U^em<']gI))O4&<%Vt9-,'$De)SBmB(5@q4SN*K,?.u<8!3UemUO/.iNEg=7`Pd%DJ?aeB,fmb_l3@#2`I-`jaWdZpVL1U&+Hti]lVKR]g&u>q7%$jE0Tnd77FOh6*k)2@ICJ:1o.u>j'_2/8bjJpEU+U!)qC/&#0TVXFrS(((dA:hAR%J;6\4?olp;eYM<S#VcEL'pUC96*P1^Js`)fsr$bd_/YuUJCMD]Q;mcNm$A3JAD0F6]!V#FW:V9[ZGA#ml-e&cp#4ni8O$l[+Lk)AS94j#2%K"X:tKcc_bas5f9@nc-]Wga#)VKMTLd(Fjl1V+c7,RNmC='=turoKiZb+qP'`.ITkXDY>FWrKT1IaC-e8<W<j_9Qhn0a/rFO^8H_>%O%BYQmN<`c[m-*A(T<dD"SC]jmp1^1n#FbaX)=p!0sO,h*qh(Mfp*Nk=*co\AS\U=I*IC6<!#/64^ib[n_fNml5/:ST2]0rOZ@hsEqHLLiNs6!tC6?t,S@X+M"Sl)Oj4oqJJ$cHiAb5F@m/TmU9HSjla*Qr?\hZqYj#f9\OglW`UlQ-=R5UhN7kp&5F2c+cj@j5^7P6aJ-84=%Ob,?V_P19^YBG\e4[lh*r"\+f3!HLUq2\"hb.&m1cZT--Z[F8"gk(_6S)^E51jPi=TaPtNFio)DAjkmN82>/Z!8L5q1P3-Z7U\5&&lr=)2W)d<~>endstream
endobj
15 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 865
>>
stream
GasIegJ6Kg&:O:SbR5&[Yh`URoa.I0$aci80HaA\.cY=XK.k&!+TB*][M,-'9C-o:aip0@1M<!S`J_.M,R=)a_5D2[(l)c79Fq6P0mnt4]7=k>[?*X?6UT%Jf[,,@=p,""7?lo"JYAML/O1.BN3NRHqJ"22<"A%P!<]&2eOBNn9IcfDq1h@XNhHW=9%fR1I=B26B8Po[__etE_9df,7;Yjg5q%q#6BoC]c]cNs3%PlK:6oIZ_*q;5AD+b:c8A:Up*OpQer?08(\cjnEg=82q#JeO6p!T`#[_O:\/"j#rirL)o>RP2c)Q!j4cL7l&VCp/SKb8@P1>d1,H8J-]LP%PSd2:i\Ce%PYMOqjs-g<R,2oor3:@_*<N.,%D.2U'q5Pmer,"3V@`2.nf@D=MX@+Cn`Q4RX[d0Rpm([&a0'%GD\@REQZXXPG]G%jIR7sN.]_sGUY1\R4k6bn&36a,nW3a!?NaP!Lhsl%,&7u=]O_Bd?$sT8@U`Rek![?TC9k?MO[X\e3Z&,C))Q@u=87`[$N#CNo2`n-.].pf'[]UVX"If*HF4d:aJL!liIQ5[^_eR_UV&33G'Yb&NX+4X,68`R*,f9gDRZ2J+M\?2Kj9%HW-g6I]eg'0)?+WpoPi*bUDqZ.P:YOs!^_3enTEqu20mcn.[`KFe2R+6Whr<@H\lYoXA#!#YF@ps1RMSR:G\+blg2]2O#PGuj\mXo&ek2KYJ:A(FD#_T9\Cu^2=3XtR\SGSuT@T(+\o";(ZOe-HJ"r2De<&P\Q+P&bjVC$HSM#f0\]8/#>a<U,G2^ZqSt-iY\Vim7eIuKT,#*5h1-IZGq2E+meG0c2l+Otu10MlajOr5F2:[NoK"k^$=QB~>endstream
endobj
xref
0 16
0000000000 65535 f
0000000061 00000 n
0000000112 00000 n
0000000219 00000 n
0000000331 00000 n
0000000436 00000 n
0000000641 00000 n
0000000846 00000 n
0000001051 00000 n
0000001256 00000 n
0000001325 00000 n
0000001621 00000 n
0000001699 00000 n
0000003902 00000 n
0000005201 00000 n
0000007453 00000 n
trailer
<<
/ID
[<812bc309659f4e8f5fe133c15b0b8ead><812bc309659f4e8f5fe133c15b0b8ead>]
% ReportLab generated PDF document -- digest (opensource)
/Info 10 0 R
/Root 9 0 R
/Size 16
>>
startxref
8409
%%EOF

View File

@@ -0,0 +1,520 @@
# Les 17 — Lesstof
## Externe APIs in diepte — OAuth, webhooks, paid APIs
**Vak:** AI-Assisted Development
**Vorige les:** Les 16 — MCP
**Volgende les:** Les 18 — Supabase Auth + RLS
---
## Inhoud
1. [Beyond simple fetch](#1-beyond-simple-fetch)
2. [Drie soorten authenticatie](#2-drie-soorten-authenticatie)
3. [OAuth in Next.js](#3-oauth-in-nextjs)
4. [Webhooks — wat en waarom](#4-webhooks--wat-en-waarom)
5. [Signature verification](#5-signature-verification)
6. [Stripe Checkout integratie](#6-stripe-checkout-integratie)
7. [Resend transactional email](#7-resend-transactional-email)
8. [Rate limiting + retry patterns](#8-rate-limiting--retry-patterns)
9. [Productie checklist](#9-productie-checklist)
---
## 1. Beyond simple fetch
In Les 15 deden we simpele externe APIs: PokéAPI, geen key, GET request, JSON terug. Voor productie-apps heb je vaak meer nodig.
Wat onderscheidt een 'productie' API-integratie van een simpele fetch:
- **Authentication** — key in header, OAuth token, of beide
- **Webhooks** — externe service belt JOUW app terug
- **Idempotency** — wat als request twee keer binnenkomt?
- **Rate limiting** — niet meer dan X calls per minuut
- **Error handling** — wat als de API down is? Retry?
- **Cost monitoring** — paid APIs lopen op
- **Security** — secrets, signatures, validation
Vandaag focus op de eerste twee — daar komen de meeste productie-bugs vandaan.
---
## 2. Drie soorten authenticatie
### API key in header
Simpelst. Key in env var, stuur mee als `Authorization` header.
```typescript
const res = await fetch("https://api.openai.com/v1/...", {
headers: {
"Authorization": `Bearer ${process.env.OPENAI_API_KEY}`,
"Content-Type": "application/json",
},
});
```
Voorbeelden: OpenAI, Anthropic, Tavily, Resend, Stripe (voor server-side calls).
### OAuth flow
Voor user-authenticatie. Gebruiker logt in via Google/GitHub/etc., jouw server krijgt access token. Zie sectie 3.
Voorbeelden: Google login, GitHub login, Spotify "login with", Slack apps.
### Webhook signatures
Geen auth voor calls *naar* die service, maar wel verificatie van calls *van* die service. Zie sectie 5.
Voorbeelden: Stripe webhooks, GitHub webhooks, Resend webhooks.
---
## 3. OAuth in Next.js
### De flow
```
1. User klikt "Login met GitHub"
2. Redirect naar github.com/login/oauth/authorize?client_id=...&redirect_uri=...
3. User logt in, geeft toestemming
4. GitHub redirect terug naar JOUW redirect_uri met ?code=xyz
5. JOUW server: POST naar github.com/login/oauth/access_token met code
6. Krijgt access_token terug
7. Optioneel: haal user-info op met token (GET api.github.com/user)
8. JOUW server zet sessie cookie
```
### Auth.js (NextAuth)
Meest populaire library. Installeren:
```bash
pnpm add next-auth@beta
```
`auth.ts`:
```typescript
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [GitHub],
});
```
`app/api/auth/[...nextauth]/route.ts`:
```typescript
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
```
`.env.local`:
```
AUTH_SECRET=... # pnpm dlx auth secret
AUTH_GITHUB_ID=...
AUTH_GITHUB_SECRET=...
```
Login button:
```tsx
import { signIn } from "@/auth";
<form action={async () => { "use server"; await signIn("github"); }}>
<button type="submit">Login met GitHub</button>
</form>
```
Klaar. Auth.js regelt de hele flow.
### Sessie ophalen
```typescript
import { auth } from "@/auth";
const session = await auth();
if (!session?.user) return <p>Niet ingelogd</p>;
return <p>Hallo {session.user.name}</p>;
```
### Wanneer NIET Auth.js
- Wil je alleen Supabase-data + auth → gebruik Supabase Auth (Les 18)
- Hele klein project, één provider → custom OAuth in 50 regels werkt prima
- Enterprise-feel met SSO → Clerk of Auth0 (paid)
---
## 4. Webhooks — wat en waarom
### Het probleem
Stel je hebt Stripe Checkout. User klikt "Betaal", redirect naar Stripe, betaalt, redirect terug naar jouw success page.
Maar wat als:
- User sluit tab voor success page laadt?
- Betaling gaat eerst pending, pas later confirmed?
- Refund — gebeurt dagen later?
**Polling Stripe-API elke seconde is inefficiënt.** Webhooks zijn de oplossing.
### Wat zijn webhooks
Webhook = HTTP endpoint dat door externe service wordt aangeroepen wanneer er iets gebeurt.
```
Stripe processes payment
Stripe sends POST to your-app.com/api/webhook/stripe
with JSON body: { type: "checkout.session.completed", data: {...} }
Your server: update database, send email, etc.
```
### Hoe registreer je een webhook
**Stripe Dashboard:**
1. Developers → Webhooks → Add endpoint
2. URL: `https://your-app.com/api/webhook/stripe`
3. Events: `checkout.session.completed`, `customer.subscription.deleted`, etc.
4. Krijg webhook signing secret terug (`whsec_...`)
**Lokaal testen** met Stripe CLI:
```bash
stripe listen --forward-to localhost:3000/api/webhook/stripe
```
CLI geeft een webhook secret terug die je tijdelijk in `.env.local` zet.
---
## 5. Signature verification
**Belangrijk:** zonder verificatie kan iedereen je webhook URL aanroepen met fake data. Een fraudeur stuurt fake "betaling completed" event → jouw server geeft premium toegang aan iemand die niets heeft betaald.
### Stripe pattern
```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(); // RAW body, geen json()
const sig = (await headers()).get("stripe-signature")!;
let event;
try {
event = stripe.webhooks.constructEvent(
body, sig, process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
}
// event is verified, veilig om te gebruiken
switch (event.type) {
case "checkout.session.completed":
// handle...
break;
}
return Response.json({ received: true });
}
```
**Cruciaal:** `req.text()`, niet `req.json()`. Stripe berekent signature over de raw bytes.
### Andere providers
- **GitHub** — `X-Hub-Signature-256` header, HMAC-SHA256 met shared secret
- **Resend** — `svix-id`, `svix-timestamp`, `svix-signature` headers
- **Eigen webhooks** — gebruik dezelfde pattern: shared secret + HMAC
### Idempotency
Wat als Stripe een event 2x stuurt (door netwerk-issues)?
```typescript
const { data: existing } = await supabase
.from("processed_events")
.select()
.eq("stripe_event_id", event.id)
.single();
if (existing) return Response.json({ received: true }); // skip
// handle event...
await supabase.from("processed_events").insert({ stripe_event_id: event.id });
```
Sla `event.id` op, check voor handle. Voorkomt dubbele betalingen, dubbele emails, dubbele credits.
---
## 6. Stripe Checkout integratie
### Setup
```bash
pnpm add stripe
```
`.env.local`:
```
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
```
### Maak Checkout session
`app/api/checkout/route.ts`:
```typescript
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const { priceId, email } = await req.json();
const session = await stripe.checkout.sessions.create({
mode: "subscription", // of "payment" voor eenmalig
line_items: [{ price: priceId, quantity: 1 }],
customer_email: email,
success_url: `${process.env.APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.APP_URL}/pricing`,
});
return Response.json({ url: session.url });
}
```
### Client redirect
```tsx
"use client";
async function subscribe() {
const res = await fetch("/api/checkout", {
method: "POST",
body: JSON.stringify({ priceId: "price_xxx", email: "test@test.com" }),
});
const { url } = await res.json();
window.location.href = url; // redirect naar Stripe
}
```
### Test cards
Stripe test mode (geen echt geld):
- `4242 4242 4242 4242` — succesvolle betaling
- `4000 0000 0000 0002` — gedeclined
- `4000 0025 0000 3155` — 3D Secure required
Elke datum in toekomst voor expiry, willekeurige 3-cijferige CVC.
---
## 7. Resend transactional email
### Setup
```bash
pnpm add resend react-email
```
`.env.local`:
```
RESEND_API_KEY=re_...
```
Voor demo: gebruik `onboarding@resend.dev` als from-address (geen eigen domain nodig).
Voor productie: registreer een eigen domain in Resend Dashboard.
### Email als React component
`emails/WelcomeEmail.tsx`:
```tsx
import { Html, Heading, Text, Button } from "@react-email/components";
export function WelcomeEmail({ name }: { name: string }) {
return (
<Html>
<Heading>Welkom {name}!</Heading>
<Text>Bedankt voor je aanmelding bij Premium.</Text>
<Button href="https://app.example.com/dashboard">
Ga naar dashboard
</Button>
</Html>
);
}
```
### Verzenden
```typescript
import { Resend } from "resend";
import { WelcomeEmail } from "@/emails/WelcomeEmail";
const resend = new Resend(process.env.RESEND_API_KEY!);
await resend.emails.send({
from: "Acme <onboarding@resend.dev>",
to: "user@example.com",
subject: "Welkom bij Premium!",
react: WelcomeEmail({ name: "Tim" }),
});
```
### Combineer met webhook
In Stripe webhook handler:
```typescript
if (event.type === "checkout.session.completed") {
const session = event.data.object;
await resend.emails.send({
from: "noreply@example.com",
to: session.customer_email!,
subject: "Welkom bij Premium!",
react: WelcomeEmail({ name: session.customer_details!.name! }),
});
}
```
User betaalt → Stripe webhook → Resend verstuurt welkomstmail. Volledig automatisch.
---
## 8. Rate limiting + retry patterns
### Exponential backoff retry
Wanneer externe API tijdelijk faalt (rate limit, server-side error, network glitch), gewoon retryen werkt vaak. Met exponential backoff: wachtinterval verdubbelt elke retry.
```typescript
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); // 1s, 2s, 4s
await new Promise(r => setTimeout(r, delay));
}
}
throw new Error("unreachable");
}
// Gebruik:
const data = await retry(() =>
fetch("https://api.flakyservice.com/").then(r => {
if (!r.ok) throw new Error("API failed");
return r.json();
})
);
```
**Wanneer NIET retryen:**
- 4xx errors (jouw fout — geen retry helpt)
- POST endpoints zonder idempotency
- Vereist user-input correctie
**Wanneer WEL:**
- 5xx errors (server-side fault)
- 429 Too Many Requests (rate limited)
- Network timeouts
### Rate limit eigen endpoints
Bescherm je eigen API tegen abuse / overuse:
```typescript
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, "10s"), // 10 calls per 10 sec
});
export async function POST(req: Request) {
const ip = req.headers.get("x-forwarded-for") ?? "unknown";
const { success, limit, remaining } = await ratelimit.limit(ip);
if (!success) {
return new Response("Too many requests", {
status: 429,
headers: { "X-RateLimit-Limit": `${limit}`, "X-RateLimit-Remaining": `${remaining}` },
});
}
// normale handler...
}
```
Upstash heeft een gratis tier (10k requests/dag). Voor productie genoeg voor middelgrote apps.
### Fail fast vs retry stilletjes
User-facing endpoints: fail fast met duidelijke error. Background jobs: retry achteraf met queue.
---
## 9. Productie checklist
Voor je live gaat met externe API integratie:
### API keys
- ✓ Test-keys voor preview, prod-keys voor productie
- ✓ Vercel env scoping per environment
- ✓ Rotate keys eens per kwartaal
- ✓ Geen keys in client-bundle (geen `NEXT_PUBLIC_`)
### Webhooks
- ✓ Signature verification ALTIJD
- ✓ Idempotency check (event.id in DB)
- ✓ Log alle webhook payloads
- ✓ Return 200 snel, doe verwerking async indien nodig
- ✓ Handle alle relevante event types
### Errors
- ✓ Exponential backoff voor transient fails
- ✓ Rate limit eigen endpoints
- ✓ Monitor met Sentry / LogRocket / Vercel Analytics
- ✓ Alerting bij webhook failures
### Security
- ✓ HTTPS only (Vercel default)
- ✓ Webhook endpoints accepteren alleen verified signatures
- ✓ User-input validatie met Zod
- ✓ Geen secrets in logs
### Cost monitoring
- ✓ Stripe Dashboard — usage + revenue
- ✓ Resend Dashboard — email count + bounces
- ✓ Set up budget alerts in services die dat ondersteunen
- ✓ Cache externe responses waar mogelijk
---
## Bronnen
- **Stripe Checkout:** https://docs.stripe.com/checkout
- **Stripe webhooks:** https://docs.stripe.com/webhooks
- **Stripe CLI:** https://docs.stripe.com/stripe-cli
- **Test cards:** https://docs.stripe.com/testing
- **Resend docs:** https://resend.com/docs
- **React Email:** https://react.email/
- **Auth.js:** https://authjs.dev/
- **Better-Auth:** https://www.better-auth.com/
- **Upstash Ratelimit:** https://upstash.com/docs/oss/sdks/ts/ratelimit
- **OAuth 2.0 spec:** https://oauth.net/2/

View File

@@ -0,0 +1,213 @@
%PDF-1.4
%<25><><EFBFBD><EFBFBD> ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R /F3 5 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/Contents 16 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
5 0 obj
<<
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
>>
endobj
6 0 obj
<<
/Contents 17 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
7 0 obj
<<
/Contents 18 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
8 0 obj
<<
/Contents 19 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
9 0 obj
<<
/Contents 20 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
10 0 obj
<<
/Contents 21 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
11 0 obj
<<
/Contents 22 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
12 0 obj
<<
/Contents 23 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 15 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
13 0 obj
<<
/PageMode /UseNone /Pages 15 0 R /Type /Catalog
>>
endobj
14 0 obj
<<
/Author (NOVI Hogeschool Utrecht) /CreationDate (D:20260521112116+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260521112116+00'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (\(unspecified\)) /Title (Les 17 Lesstof) /Trapped /False
>>
endobj
15 0 obj
<<
/Count 8 /Kids [ 4 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R ] /Type /Pages
>>
endobj
16 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1670
>>
stream
Gatm;bBDVu&Dcq.As7:@-V!SJ6*NH6ebU7EEGRMcGXGDk7%5A8'!e!UI0Y-uZ=HFijWb*Hk&H0#mQ_1/^0(1P/HO^&i9_lrN5K01bU"l80&bj/q;Dl;\aOi0KVB[K5QYBo+J1_EG`p-u#7M=>M=Oqk'dH<S$96c854]rL0KXCo&78=B\Ngl#UlnsUJk40AZqEM1K2)5(5@r:OLodr#1ol+JL-&KHWq@iH6CI"`0Nl97k:b^Fi6Q5BMeJ5a$2eX\*A%]TEpgj@`S#N6`.cT;pn/^OP-($228?ug*[[dC<mSPHEoLMR6iWLa!6c64KumqlgT,97V]i:9T<DAG`f]>)U/E])#G/_DZ+&YO7(];UqHd05$4/<d_Psp"oVsR0i8%R'jKo<7"JMF-XDN/W'&g.s>&T?'eZLf4HREi[?RJShXM$t_DmTlf3>Bq5n@d[c?gM(ZTX/V,0b^Du(PnV,<CBCsL"/.(FtV.<n5UPlpBajL;]U$&e@XFBm2_+u?*r8=41+TK]3tXX]636JO"nU7?nUCZ$a<^$(:13E'Ae2T(t(;M&rYAtdUOtS3:d:ii+%Y/qE^ai-$)<M@s";W35<$Q+AQ>`l]ol8IOGq`Q&6aY7L;W$REYthHoYQ'`>a'MGM%T[`>_p5n[&X%#%$EEF<-cOT<1GDB;nX5C`\Pq>OfPll#16H^lu.]nACs]f1hei93NOtXl;53'SNJ[&7"lme/9O&Kk-*I4"4b4jBMEKq]P-P<WR6bemb_!2Y?sR:^t;cETggRFT.XRF3%B8@i64p"&$`p=ZuJnhI#ZfE#8`%?`-g3M(V8J3#mRN0\ia2iE]PndicWg2-aMFCdn<6&\Vb\kMaK\f`)mAmP_SLh69%;YQ)D-:Sgr;=Fr&U8gI6/i9'jDE,fJK%Y$IAmPN3=0]?<3;,JM>;%g)!N4-$M8gmos/VR)K64ILm$e[,ZCU[\"B1[SeL!f;C0Pf>EY_reSm5:m!g<[.6USYj6Y9*,)oq(H>QMF"SJFBCt3j8&KDdagj%2?e8Tdk>dT-U?^D6:1HO5nHe.Y/Tl*(UN>ZY@2t7[`@7`LN*nV0MDLpm,5IT4U-`<R?JC/";j$@/8@7F)oHO+$SbjL*Ff]UJoO1Q:$JX7(nDO&1&)5FP7#%ohsJ/$4X<eHNN7r*oF0Ja6So<jPCB!J$h"8<o7[YLFb<D@6-&YMKk_IArZJ1e9O079:M,LiJHhu^?+LsjRGj[qh:%OZF(R1!t,4jjXSYYoCTKa[4]T,Sqh"lU/dM6$0Yq;-^_>.0[JI?r9%4Gc/T(qg0h<hG3YG3:%(Em@Q)uF\8$&9aJPAu)T*fNH;NfTSuqR.%1n(75+Vk#C4O)qc%`o%o)\;jX,c<;R9.OROY3s4FJg[u1;Q)^(NX[3?m@2lCO&+Iif*6JdA?O3],0b!eGGg9R3>ppP#F'`-<faEL4J>^!:_dojC*QCI6ZrgICS(-i"BO=\VK.D1^W?oBC%?PTm^VL;:)g_cnqH)rA>eVi91RB8ZS-<Z/I-"d7Nj@k1Obs0R=F@hABYmYfCjlU%aju=h4jeE?ml2J97GNCsJe=ZJM99N_]?o:`Uqt]BA5r<6%iB2$2<Z,&nVA4R:&2fYm"o:ER-<m!A^Q7jrs$JG<Q'SiLKNJTI>q91QcXE7j0Rq9>Q'h/N^jrr?[&W5/~>endstream
endobj
17 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1656
>>
stream
Gb"/&D0+/c&H;*)Z.$5oKdj#I^I[e;btU2*'Gt!r^th[s^ibl0e1[f'ZkS8pIOdL/Bq#pm]?@*F19iF7G5#-&]36q^pgr!*S,teo_!Y,*N5K$+bTe/;?GXIn3Hk`>\F=kgY,#sVJV:3)5V[F;n,j;V4CfS6+B!IB8OfKa-#fA%";=T[(d2sX&.q_^4Wop`JIHG@G=fS1ReDOZ"Q@W$F8r%Y0-nBh4*7h.Te_j?F&8u>f-?OU_M:DagTQZ%*5_;pXNGdNk6\d[Ko.5^:,j&oJ,!]l/9P[V&>#g.Q/pCnA`a"%^;/[#[FNapH(=ZnhKAe#<!hbA?:pEBC:2+0=D&68St7kXdmd3MkDfm!M<e&jCU#YQNf/INXqu<`U^m,jIG9c.%Msdr:\Mf<S4e-\M1G=jr'i#Vg+g6KOtj#;*=>XroV'h&Z<F,JTG`R5_<*q$(qsS;9A1kiXd.P4SJMSrZqemo[kN-L@qXe#S?h$ul$&b'mSr6VLn)qh[q(F1/fAkaTjCgsZ<B$7>8P0A0]0P:oEUZ\ORV$$/L+AR]Y13BZ+A)a+d>$Y-:hWXilO5."<hWME8J'STs6X]Q'kXT;X4fR;u__0C`d7cZ-\9")QF2u'ccCLCB+(5VPA9%,m96dHoR%D<hN*AZCrbj]k`Z$?#-dg&,NHVl?*c2`Z<(R_U6So(_^*!=[DH08?7j(8h8-?-VT>m?6[L=L6Ut^/WUoZ'YF=3R:+&#3&3P@.Q\ZPm5f]/l3_V;',=rs!0d?%c2rVQcB`c'!Kh7.IUh](kWc(G/;e*%@bg7L;+7@T<5jQO'qD;B^l+u^+!I(J!T2/A/)]HrYVGp4XZOBa*3++L)jSBDM^.^c-)7.8g_hjLr%%[L7k[q/p4LUUDJ\,@o%V5689CkkcGkc.VpST@'B1E38sgg];k;0FAd*!PD@Ui"l]/1[MBX]]Jjh=qMRu]^\?b?^MKFH"V>5NCBYM=B`p[!`rl@_7BqB%&(J@V$,<Z1b.S@M=#<.]k\dJ'61#EB03u-'KD3gu=WFB,nEX-g,E>FsFU%&%*9(]?grdC^$gi0FPY'l%QYO,j!n(TbQ\+0L.DgPAiCK0GAJNEp$]uE=>CYq5l2LIJkj4^6oW$>0Y@G>sBf,B[GWR=tj2+#mEV[,a((EU3BCX("ZNHe2C]Z-L'Se1RWXW:%S:Js9Z)f!]A!MLPLr=R`(`(iB@6@5/rI\j',_lN*D*ua7hSI;hHZm2NLHa&/;;YFGG:)WG8=\d*L"7:K,cgT5&[gP4\6<GfB>suerKUd$U6:$"FF]l\N37"hI@N!#-+M*N=WOr+mmB)4"drKAN2k"JjlY<4eS>Wt8CmP^=[0,;&T.q98SkfDhe?jlh&^%e0ZWrh&Qh+f-^m8h:Z1rgLlJ'l2I"&/k!r)=o^/'YL`\T,Bf`aAs5'Xbjp^*qol/f`g5<eP;2T'MLlnNp+k/SD1k%^`BS36Rh>r&sAB^#@iPGrHL7nJ1*'3XDhc$#T!69FQ1QM(?;#0a;IIWV].&9t'IMjZcHLaW#NfVH7.dET[W#JT9*bN]B;cO0US7AJKf:OU;,5N+a@\pl/Bo0Yf^P?`$tLZ/2aV[>Se",<:bL-[lD;h`9@\d'NhUO0"?JnNh$G\7@H`K373k5"@c#MuHnAT($DSA,(O$;,d~>endstream
endobj
18 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1509
>>
stream
Gb!#[?'E_q'Rf_Z\D=&6ZRG[YbVpQ8cn[.(Tm^T8N,c4]fu+B/!YgChhJ:f1*S>7;QsiN@Z$e3rH`hJY;8RkCS,)Mh"7YmjV@OLt%^Rs!P":s'LAS=@F[Wb%YuEuATd&G\'%+@b>"/ZD9m"5c#):.9`"JkS`0eICa62HL`DB\s]+Qh@Z)4dg5R^TFkg,h:7!"Bf,uIhnT0D0&1C_$Nk`-\3Cd/2[,?S#H5S]GYVIB_8%8<@`rqQg@Q:kFpX0Q!JF'%%2j^?AZ%!Ne!]j'*6mHaoBff+([DE#"Ca"ViDZR\bQ&\aL".&=>5s4GnQI.:%FnuROnRP+1U"Ymmi*Tsd7d_$eC;aZ520s8Kl'-7IM(aGiB)E&1/Ucf0lFi7cVWAL@e2r,lR=KM<XUA?TI'Ol=($tB`G#UMG_;%Tr8CW?PZI3:n/rV8K`GaAjuB`OkN9,>p+`>UB2amS+a`i/GZ>")arc+@9Xl%;Ge?G)_o:*-/VA!CrM\,_8n_C4H2CqMBR[F>'<EJ1MYIR<hmpl-J"K"BdU5J=[M/'&*C@c'3:W]h[1D.o$IlN<)Jk[<*))GP`8IrZ(sAmN-)q':^KV;&+(k/Z*I(gP-J2.ogp;IgK"_VPaB<g$dHf^+:#7;j@fa)0^hfK,0t%6cb)JiRL2-&oqWduVVF&7ao1;@)EY6;/Oa.A-oYBKU`??k=Lf2-_tQN/\_2V?7YUE]&G*6Oe.qg-%Z"1Rqc"8-+QS%XO6Y'9[=S2.0]V@:Cl+7c8Q/.l^o!k73LnQY7GEYmu6)h8A'lUPms,LXHKkdMVDG3.X<\,"IE0:o%e/bM94C01f2^VX"G1>,J7CC%G>*q0RHM>N][E0Cm(V6HJ8V<(rOK/Dif8Bgd(lob1G2Hfi,/(<ps-ac#9B?kV'V`,37[dZo]mhiW,kg+E7Kgs4&r^%q*RpQsnH6"7ZU$`F@/HM[Om5_<ojFUaq65+AtOiQ?4GJ1VPk1,#.nd,88kbu'2>[cF\NJ`!L*'ipl;)4LP_(=El1*2YqY..J?GG`Ot%"NrCF.F3'^a>+It6%-@oX(=,*KeA(FdJ-^_2*XPgDRVcF)^5lIa[H./[Xm0IQZCtAE9E?UNGJ\U,sQ(INBZ?=UV@X^UQ<b&N"3D4ZY_PE`O,3Z38#)%%A\mc'Y%mZRuY5Sd^o($"pM;'N(<[C;1&!nSO*ER>l>C,P]+!Aje*D(S5nYTNWR\)lX+'X*EHebT--k;<V[tKfW(OO??Z[$A3=5_-MF"VT9Vk4hug(/@^3q#-[o5oT[&nCUS9oCmMpV-S"]9GjMP&$$U?-&)Z8FBqp3BNS'&eInb#8h+g_RcqlRm.XECUb]1U\`3,B?m;1okd".4eZ+.nKh4uNd[#j(ggAtbB*:gahYNELpm?ksnP#;A4"qT`]XVr=e65'Y>H7,'?*1]<JKgs]rk"b*X)$WMHK2U.d_SPSt@G<i5ir`>Qde,eAP45:-^gSo/HW=$/?^%EY[bn#^Yr[1o&DG7OKp8&5GIp^l?E<~>endstream
endobj
19 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2347
>>
stream
Gb"/'D/\/e&H8h>E@Xl8.3+)<W4*Jqd$GHk(,TacXk7$S:h7RH,>4%Ba#/bPpDlh^P4RfO_n(8`D-.EFhAu'V]6e2q']2T6-ZaeS,M7@fJ8lEs!u")<kQhYfcD<n<cNE,,EC9"\*(].j(R.iG#L=cH`$5I0%7h<A&0W9,(kobF>nF4?8dXZV*<meYBb1Uo+nPWY^U;oE53JkJ&C:*GS*M$j6=jIkr5LcSdsOt/c="noXAi74b.PNBd2W@/O+0BI2oS`safHmGpoQ/[194j-GlZ/$GFqr:>1IWpYN1tmMT&i_oaZpE_<@lHeWVWh-Q\_c.K;q>JG/!!Wf(pCp*?6,GmpDpk8jE+($+u+d"$khF_QB2GgPE0MiJ*Li<pai%ekdi[:>WB"%&smHXf0SAdQ<UTD(jgG#%&L0t^p<@nMt+MJun_JX,UX1WLE\%O>^qH_3;,Iq@Gse-_pN9B+=DM@:T.Oj.@INS_qoBBEl$WOE34hJ7fc%:5Z[%6Sq[A!HJEE>KJ7F[Ih3+m[1&0]V;bf:I@sbDuqD/RKZQ7>T\-q&eZ86LCL&?s(B*a;cjQ=j4>u_N1WaKM"QbT1tuR]9FA-\^Y/m19K(75Jio;kQI/G(.F\D0L6'n78\[7>g"mh:s&8X9dHftE`,7Ek>PM!EhTVZF")*@$)t!VR*%4q/8MP"pYD.1Y?dF:$HnC&="sI;eR2Qj)63r"`bTWc"G&!AFt@]&8CbRpaAs+L:dKa58D)3ZS8M@m!p4hrMe^9V'(J4g.O.&`bp7(U*C3QYK7\.UL:s3n-]E5['V`K_&%&[N=l8VD]=em*/jqatoI`BZc&?.1Hc1+1"1Qaj9G\<WAW^@4_K*#>fCr@O<eBY#^ua@-!8.5V)EAPlVLk#ldh1(5XG^Z#)_VsP.HZ!I,2HH@OR6%op;8pNH=,4o7L-Y.Bg%)U:64;L=YX(J_\]9'$b6dDP*nurMIfM$"'&Oc9&ChV=I>]h'nBea9*i(_l\AKK=1bLtE!%$JZJ:5s!_#[4mjtX&:i@-14AN4+a>tL!o$[uW7JZ&2ic-JSD6;6;*?5e1EL]<HZ2N`;QZX6uQUchco?D?)_AN4^jI=?iR>/tK1F*[:<7gS:bk6+!\UKlGGYd]FWapP@9^2`"?lH=M7q?i<]I6j$3SDA6aGM7gAjo_g]9La!1Z?W(M9$uL7/d.4rHIZ%hETf]:Xa#!f1Vp?=CBSK1l6"L?MAud1/rgRDS^;V4?etM(BJ;L-B\'M2'c>9:coUsbqXWoP<K[9HpAb>\a#ka\lG'(NufR['P;*BIDV`IMb])sGLH"F_6nO0)Mu2Nc"78^OqPEPpbQb6QuB"l9OJ&HSjMj-<[IG$?EbbF!V]!8#D%uK??;4$g4Ine?$n?=T5'.hENq?UgZ+,"\ko0UM]3D_AL]`q([8^j-/a4P+F38IRF8$bf&L,1_V(>4pp8$h8<S)Af5M>baHpj2@bj#)0-oIa,"MW3.;7cF%b,_@0.0Y?UO%ceb053Q-L3Wao-nbjcg\qM`H'MgBOD_qdP6#RA#G[bF?*F@%COJko_0VrODDiodo2L\31J^cD=?oY-S03C(*iA<pCf(eKLaEZ=5b@Am%YSNiuU(AX9Ec8[2AdK)iYl3SM8d^qT`B%EtlKj3C9V77=f^YK!C4G6PWiN<f/n"b)rps21ps]D<"g@9\>*8L/&RgVFtck67EL'dY-`]=Y)rG+sOBLq,o5+X*(#Lq(b`F!S%$e4=K=Q5.m5K9B8(m*Vf30Ebl+c'gpB1\"q*G>4Y-EdDW(FXg'olOrBlH*fP<7_8^8/&)KPNTVqgo7?&rWju:dhknLOa#=_MLF/<K]6`(J22@eNEg4K?<]^s#VD_d@uG_0eh'F9tc[J!u;0\'[&E,;b@.a;R.?*:[5QXP(KBOpA;<eK-b>9aTNWJG^h\P28j2.8W",*mOCT7JQ:f23A7T+9Uc$'G#u3?>dt4?<33iS_\Ej5XK2SfYOlIk]Rh/Yl+R#7:Hir51%hB"4GJZ>N]J"8:edL9["T\K-?J\.\pjPgri-N-5]A>To92"hI/I)IC>qkb'WJjK310YP(7Y$J/LAoT0'GNN>#.iRtp)hH-?50Hj,;e#M@RE\bSF1de3O7DNsUT'jd5H6Mlu3lX#eBq7OR<CK(mHr8^+VH7fDO'#Z0YOFiMH=(UNcckusJ%E;QGh)-QoOdP5]L4HOfF[doIrF'97R\3[(93/6ShTo4,C&A.P!\j#11%fM)Gb8'BM40l]VEs4hLQm&FpENcm!(LP=QAq9P,uDb1#5\(NINFs=PL0^D'k.u(g-=S%?^H(::&R]P&pcE!g(X?H8,PNVM?\pcdN:o8g\I;$(Sa13Tj<R~>endstream
endobj
20 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1636
>>
stream
Gau`SgMYb8&:N_C9S5MW6+Ybh\P!`.OZ3\W,`!IFBi.i`_GG$9M+#K3'fRiEmi?Gj,o,0DMZTk+n1rc^F(XS$Ka]@NX?O[?JEKiJ^LY,>9J:of,%:<P-<'BU,<XmVqET_2,32!U;>hVEH7AUi#so[gcr:UZ,Y619etE;.rKLauEW[+R49e1W<+uY,1I'7&Sc43DS>0jkr#c9[mGIE^'to\3%6LA"rs4?2=;-od'p;DU.j(X((qi*.ob_"H92icq=giqVAT$MN&n[XaG]s#6Z_C!JdOa=1q-mCXAo5ue0hagJoL\/FLG'r$5183)Iq&gN^3D*Lm)$W27a!':_!g6p_jn/#!H3cDWeh9d@4h1"g_6%l/S#t+=F8gNp=@nt*`n"mWI$P+RoY\+(%ArVDqi4%Zf)ABN)`^"'On]$#DXmuD;to/L):6*pFD<`>LpK`%Z!/!Yt$kk.su:]S'EXF?KAA8l.)6&%U8f"b=Xk(GQXZZH1X_K.,gV7!=Wt%p2sZHTN;rHCmr,Pp@*\Q5:$.ZI\d;cU2G2Ecc\H((c])`n`O^m6B[XHR7CgtL'/*-8u*P25qn2C3.fr9$DS@>%QVD.la8+*ZD5b!<h@gJlI_N[&.Jo1<:qiSD%h9AVS[(U;SRs$gYl-<mR%Ab7FT=4E#+,l*\'l4Xa@6c#=^d^Pq7"n"7<TVrOZaSgO!KT+KVV5mo)YG6I%QN"%@Sd"KRHh8N1F@((2PUlJ4Z.Zot6]!hnmo5MSd8U'0X['B:eYPC=[U/<3d._)Z,)RK]gZVJ!rIPPP%8k\N#F^Rk@P=F?^,VC:V\OSq0+[0Q<XjS>j?PLg9!iUpbjibsA8eYQdXV`kjns12a7-C>b/f-R=sB!%oY-)]NVEX\J7d\V;0\o\_AJ^McpGLe'8Ku=La66i"6ZPc9&A/mNAauIG*+_@pnr[a.Rq.c:-]ufgXR]5NqcdqU,.fIc@"#)pZ+I-LOB5(4$6NdlB2:i;uCp_<j(`1"lj`a]$_sHoT)d29nL&8n)_\FFrJL+h*WagpDYA"C]gGCOH/;;Q:oBC(US#ZWN1KRd#.^R071Z4e^)Q0N.'nD;7"u^`>S)_Id0^C,i_(A+r>t7JC]9=c9WQ<N@BK.X0',L@`#J.]=<Er`jRo%NKQo+7>''CSk0t@Da6\27Q'a+58B+&d2(#FBj@YClsoB>Ff$li!9RLU@X.S7lI.F#F`J3TK5*-=3Og.#eEP[=q4j7^e"n/ruSC<[cD]Z'9dg>qid[0JV-"!B\sFTkZZj%=@`D2nlDI_=o<nCd'jr;G<<gMt^+QR8X%A8uj8oP/F=qW.Sh'mb=XI.DH0r8RFfJG7ojD^1(dfW=fDY*,'Gb9?)?Plfle:fKML"J?aXD4[KWM[bQ`1t'$fRTgFa_2!Mjgg;mULR&,!n[N/hC1&RBG/h4%"lA"EeMH/tM?=T7RP0O=YC41A#+tYgde\)J_cE8@)OA!RRC9k"_@>%N2qnj>[@$X]d:gOaHB9CiH[UX<>j(E*TS9?\!t1#K9Dd&%0To]#]!ZUZI<C[3lplf\op9L&1@W\U&J!FSK<2(!me_^pqYI[p;i6S3V[#OqRVS*<9to6rmro37^I2;nJ?(cJI/j1Z`="XanMW=MR(TI1nGWUn*MUg~>endstream
endobj
21 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1556
>>
stream
Gb!#[D/\/e&H8h>EQol/BPEVR74Lr_VJ(i&/%W1\3Xc9jW%sRJeOq)8Bs8`[I7Ho(ACn.C%-SHGOaK>VGJ&:jU!/&HMn/k%%e+98o+qfPLW(,b5m/`F_oX+4$j_p(&e[a6Bc(n6EG5BF3Y)RZ&W's<1XK)5XTSpSLt8g8NpDp=$7Z-j%g*#(79!t%6o,_Fke$L2M)`Q&K#f?t2qN9LBq&F&U+SN$Z[F&8HE_9c*]4Xb18uNMEgXQ9jhiTn=e;*DrEPX+r74gorS51;k6d5V4[-%7<Sa#g4?(k=.kbpA2#FNP!fMgled,Ug:*c^GNrRh_JGJ3$DT]:$p2+ia.M@P,3:F)K4]')/6l"5t%&q*Mln!Am@UX@,+q[*^E(SbfDp&3b#c0kKCg;0jJhRr0Ysn@MC#EiQ__bj=b*ul4M^<W7OL789J_lJtK)L*50`74G?W%D&I?ZR]6P^Pgn4@e]rAI,,:P6uEj-4u"Xe3A4gi;D7M!jI]C)Ne"CW8_LL`:*.?-C`r08-`EhRnm<,DR?Sr]Ru,"H/DOC\[?mD2KVr<Dq=<g8rF@ahZuZ.[qVM'QOi?JZsf8'VTMur3[i*,6X#HiGId@&5'@ZkA=?E1pm\rP982.:dNXaU3iLh(q^P[/<[k9osU7+>e+im_%l:+Hj8GZ:r<O]$)R=YCH@!n+dSsY*qhjS>eB[HDMHoJZj7gu?)-.V@3[RgP9EL3WjZOV`C19YEf%ZnRTCBnguHYVD#jr1U-d-Gb27Pc/>6'%(Mc2>@Z"raQ"`")hHq;*:4>?OoDIY=gK,JH55F51ANg_n-5f7+B@Q#GgR18HKFKmZ/m$!EM6-t-=Ujh:FW`:-Mm?I:bBM^i&7J82PjH.42Nt5e,TGK)Pi!n]`+DoU:-JbuZ,H>X4L)SR%UaC7on/+G:hO8QM4SRE`qB7RP@;XnCEmQYS))S(%!>q)fR^-.do?LZn?U0B8+=qPb>iQ]#SuR"JNGilarL,HNeV06a\hO*1_GO9il6K2"N=JC)=)HAk/O6=G#^MIL%3G`B=C\_\Uh?pB^ZG(DCS<rmS<N8G'>]4M,4O!#oa(F#+DTmJCE3><@DHGB[+.h!/J!X8%TR/7;O73(YNA:@?5@p?a6E92Z:5mL\'B:)f[EB*M@8rd5/+&MQ[IoQXp>-$m\E,S'1qs_n#BK^W*\9S)7_*CRPKI\j+#`Z.@`*l'c#($d-67[1B/2'/eY]dlPLRdo+3c#9C_&1+pMVNo?1fOE..,p^lSX"s*"]:<32AK9#U?3e5U$erSCeOYR=Xe3..2Wm`$A;^/Op+)+m-9n1SUJ\lg#5Yl#/<LMjA]K;b;&)7DR"<M@!4gcL0QE9pV81W/b9k6Sg2T3M!iW/\4;q/LuZM7BGPo]=k*V.W`+725f-fWO<#^9:<lD_<cnr(^''\Fjj%@sH$5(,/"K0ZQ/4d]EfTc!2W3S5hLLq-XLHWJNA0\998]nP2g*7*u#_CrBs#BRYBh61F*)2pcfg37ZLZf`[U]"&B<iiU7;CHjkX*bIn"D,i3ln3"V5RFShs?s2KT$aQHu&Gu]"Ng.0~>endstream
endobj
22 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2022
>>
stream
Gb"/'>>s99'RnB331Hhm"sM*o/^r/$mZc!PdgOu+gR#Pi7nF,LS_=F8mI9$-a;<Mp^h#/mG@h.hk?.T+e`V<<",\1$rf:([^J&&(2$:j,1'].^JB8`iS.*+?#kth^>Tf'&:;DD`Nqn6R0hB.RL_oQ2JYYJl^&T;oOYW$6EM)\U+*%PV-NaRkpS]_K&BS0QHFNT0`8_h;$fZJ=E-$MGT/>[6`2g"-1TOjc_\K7rb+^qK;!.U,9NEinp\rVRr0f3:&M:=?JWDind8IlHF!"^0mCk-/\BnspYLL*TZLphdH3Ydp0R7,LFmt+C`'s:#j1eFpTY5RuF>g=1O2FDVL>[8p5%%&'+HP-/qG\*JTg%VuhLH+V%#f:]-TkK?\4`i#.n^*%-^4tkO`=O-&qp'%mn2AI9f0(04COC-"(an%Xp\L=fFFt5=9BI-KEa?taai>Mem>-fLfuCXJ@8K(pr+.SDmjP*4HB]i6!7.K;l+s3[Af_&GWd8:o&um.;8OX>%PYhDUasWQa\H.JX298UfYR;Sr.+:kpnf6;J9?2Js0kfT8E*XNj)b;tZ'KN]$0tE'Q)@&X;0\8XUA3/H+1(^J0Ub)^."a68r4:0O/Ef&ei$=9,ZMD/=k*cldbn-IB"@_KJD)-uTilu;#<K3<PM0D2C<>8I9iVQV"Yd.d^clq2sMJTck#SkkX8n>7T)H[jb,f-@n9;n`6:l6XKFIii0;dM&](0Cl0[9lUQqREksck_SK\kIh#ed'p_>46CCBhu3=Vuo>R&\>)0!toRmZ0Zn"\:H3;3!tSe1kPNMNHC\e<Pk>'?;*lDqi\qf7%(Y""(7_g_fR`kJV+bXS7en4:^AiE_/MEGkDL7rPjE.e$TJ-aDl59TA<$;HYY_N&&mi:0Ri/'0B03PV_"T=te"-GtamcEF/&MSQ;sSXnUYgpV&>Nk6$kL[d,P^Zj;iF9i,]sFbga3F0jp(fs19nr"#VX%s@sh!mT>B(V:k=8Ld:Cfn7A5a-BVET?IRC?r*EV1thWtH&EQF*c[=a)i4hJK@9/#o'!YG+ZrLdI^KTM@@5bfk^1sAY10PK;'4b:)9?BTGS0cfd=`5ZB@gB1K8J`E@NmT8QI`&POnb,Y65lCpl@FHi7E/X2jF/!Xs.$HA>gdiDD4^BWX0cgdi3d$#$]+hkj>ZbC'0a+e(P8\M\BYqc22ca/![)K*u.L!_Y<8^sureE1G[Y5/?RE!"dFs6_$)*2]]53P7F7S5]qdV0C48Q:UgW$YXgrBkq=jQ3t.`N6QeXNi\5SS(-5%FG3lgd*\*RrgcM&]l4jP$*3AM?j5#JMA*Z!bni&H?b`%6--M4(Z^k]t$g\lQ4beYgFk;>D9%Ni6)pB"A4@`U]er?filoOq3qqicodb-[>;f!1uAEaL1+eE-*aL"R7]=@pQpP@])-0IIk>TnNN9qh.63*gGZ[^$kj^5g_qSKI:Z3f_D_Yrf'6J"Yd)(/c+Jl0.0=csa2Lme$*(A0O5<othK_7,W3e(DAcl4=VrU@`#)[=UV=L:bV^b;D0Po/Jr>I4Z;uudYAZXMHo2?2efA!GQi/Y;DA#o;^H?8D_;KG])=K%d3>Vo>\l\`i]b:5RJ9P*:ec+K1JW-M]rZS0;%Ec>5o*'KpF@VIET&T72a"$o*St0)V.u:a[s!iWM.cUnoJoZZL2&D'L*<>2YSXl4ri^VqE-M/.kL?[Zp^N*T']DtSVXC+S0K,r,Nb,kP&-XJ]geTd['"FB;Q3gFm5.VVH1n8(oXMXUM3&X7i:iA#g!dXbSDn!1ff-P[:3rcs(Ggoe'_8#o*!K$Da@tksk9,4!D3!4LX_=;@g4FGFdpf)BN#2?\?72Y=gIeIHr,O/e=EV@J_Z,`3O7rY=XX(S'US^BW7<Tm_0$-l8hf"!i&e\rA$ZIq*\2B,!qAS^mtBT7sEV<:&[d<o=q5-'lYQCVlL8J*URCg4H%F-Xf;9*c1OT;)?+(0VID(m5<F&RHee3%KG+B)9;VPg>+h@\I\SOrKa?A34KF_^\;hS`P("=W%&CHD>d,~>endstream
endobj
23 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1017
>>
stream
Gb!;bbAQ&q&DcY&Asll!1eCOiFa*'X%1r02G&boh68>n]=\M7F7K:)sVj5nSOY2SsbHU1RH01V7c%lcV?EF#]COd3=/D`'g*3q)&JdBFMi,of`NsM(WM$-ANBq02dC#"01=[k7oP/rlc$oCsP.fhsmU33Jej$'<,K#Y[XZ\SWZOt&&6BU21@GP9'FEfR:+8Yf$%mN;5gQ<m2E^^b)P?Ne7]C_/It`e%$QW!'C`S@j4Koc2i"V=eH^>QnJ$A'HL=2pN/.SNf@1^@]5WnB0V@b2'4;*WCWV)R="XY8P%cS0UYo55p9/J:-nLK=gDW5NL"nc9]cq5%730LaihD:8ks5i&'huHVIdHBU9Gtj^d/-@Aa!h"'M')V]]0CM`6%%m.,PpN@B"2igaQ'8A^a+PHS9-*bGdQq!`-dE:V\N9iHe=SV+&7PCMP.Q@8N:Y-*5F_U@-V]JYZX*m,Bp<FYl%iBLDZ?!`db*_KKu8eN@g`'F5t**tZJG%4A0IWaoGVXUg5FW"FolVk=K'MBAfYA=mM.@Anl/67A;Fg\]-LZ-8Y9I1dP/#bLbA#`_LLC-&!Z/B^TUl_g@GXD8cA?-oW#c0l6Cg7LO"CXpn\Kr-FQtWea!+X&QW6(:`\hnsE)^F.?R0\`UK*957'eu4igIXF,dE.<_,"f<1l'CD7H'0m\DPC.\or@^(bE$jM`@"%d4VZg*nIhUbEiss:DQFN-[/rg1.3bjSo>CILJa=APK^Us8>7F61+l7;0U2b<mlef+87*$hU:MMD=*O2eAc5nVe[^WMpVS'>+Eb3LTgC0(SE5iRKbn/pd$T$!JkT&h!>=\PEhJl*O%ss/:XrG,_l8X,UWY3@93r8]jXBqFc=4n+Vj87ecc9r^LkNM5g3^6bcl_<"`FQ6,&/,+Ru=<C>kkj-@-EYIC(3\RO2*?'`N%>Wfts/V@-?=dc=YTh[dp#Yu3g69D1r?[5^]op+bqLBq)pll"fQ0WJSU7:"%4cp-8rPB@0IM)LP):q&m%Y<g[.0[h<~>endstream
endobj
xref
0 24
0000000000 65535 f
0000000061 00000 n
0000000112 00000 n
0000000219 00000 n
0000000331 00000 n
0000000536 00000 n
0000000641 00000 n
0000000846 00000 n
0000001051 00000 n
0000001256 00000 n
0000001461 00000 n
0000001667 00000 n
0000001873 00000 n
0000002079 00000 n
0000002149 00000 n
0000002441 00000 n
0000002546 00000 n
0000004308 00000 n
0000006056 00000 n
0000007657 00000 n
0000010096 00000 n
0000011824 00000 n
0000013472 00000 n
0000015586 00000 n
trailer
<<
/ID
[<82345fc9d3ba8a3c7370d1c1e79ce723><82345fc9d3ba8a3c7370d1c1e79ce723>]
% ReportLab generated PDF document -- digest (opensource)
/Info 14 0 R
/Root 13 0 R
/Size 24
>>
startxref
16695
%%EOF

View File

@@ -0,0 +1,418 @@
# Les 17 — Externe APIs in diepte
## Slide Overzicht (Klas A — 3 uur fysiek, demo-driven)
**Lesvorm:** Tim demonstreert klassikaal. Studenten kijken. Zelf bouwen = huiswerk.
**Demo-app:** Subscription-dashboard (Stripe Checkout + Resend email)
**Vervolg op:** Les 16 — MCP servers
**Aansluit op:** Les 18 — Supabase Auth + RLS
---
## Slide 1: Title
### Les 17 — Externe APIs in diepte
**Visual:** "Les 17" BLUE, "Externe APIs in diepte" BLACK, subtitle "OAuth, webhooks, Stripe en Resend — productie-patronen"
---
## Slide 2: Terugblik
### Waar staan we?
**Lessen 11-16:**
- AI SDK, tool calling, agents, RAG, Cursor+Vercel deploy, MCP
**Externe APIs deden we al in Les 15 (PokéAPI):** simpel, geen key, GET requests. Productie is anders.
**Vandaag — de echte wereld:**
- Login met Google/GitHub (OAuth)
- Iemand doet een betaling — server moet dat weten (webhooks)
- Betaalde APIs zoals Stripe en Resend
- Wat als de API faalt? Retry-logic
- Hoe doe je dat veilig en netjes?
---
## Slide 3: Planning
### Vandaag — 180 minuten
| Onderwerp | Duur |
|-----------|------|
| Terugblik + API-types | 15 min |
| Theorie: OAuth basics | 20 min |
| Theorie: Webhooks + verifying | 20 min |
| **Live Demo 1** — Stripe Checkout integratie | 25 min |
| **Live Demo 2** — Webhook ontvangen + verify | 25 min |
| **Pauze** | 15 min |
| **Live Demo 3** — Resend transactional email | 25 min |
| **Live Demo 4** — Rate limit + retry pattern | 15 min |
| Productie checklist | 10 min |
| Lesopdracht + Huiswerk | 10 min |
---
## Slide 4: Drie soorten externe API integratie
### Welke heb je nodig?
| Type | Voorbeeld | Wat moet je weten |
|------|-----------|-------------------|
| **Simple fetch** | PokéAPI, Open-Meteo | URL + JSON parsing (Les 15) |
| **API key in header** | OpenAI, Tavily, Anthropic | Env vars, server-side fetch |
| **OAuth flow** | Google login, GitHub | Redirect dance, tokens, sessies |
| **Webhook** | Stripe, GitHub, Slack | POST endpoint, signature verify |
**Vandaag focus op de laatste twee** — daar komen 80% van productie-API-bugs vandaan.
---
## Slide 5: OAuth — wat en waarom
### Login met Google / GitHub / etc.
**Het probleem:** je wilt dat gebruikers inloggen zonder zelf wachtwoorden te beheren.
**OAuth flow (kort):**
```
1. User klikt "Login met GitHub"
2. Redirect naar github.com/login/oauth/authorize
3. User logt in op GitHub + geeft toestemming
4. GitHub redirect terug naar JOUW app met code
5. JOUW server exchanged code voor access_token
6. JOUW server haalt user-data op met token
7. JOUW server zet sessie / cookie
```
**In Next.js — drie populaire libraries:**
- **Auth.js (NextAuth v5)** — meest gebruikt
- **Better-Auth** — moderne challenger, eenvoudiger
- **Clerk** — paid, alles inclusief
Voor dit vak: Auth.js — open source, gratis, werkt overal.
---
## Slide 6: Webhooks — wat en waarom
### Externe service belt JOUW app
**Het probleem:** soms moet je weten wanneer iets gebeurt op een externe service.
**Voorbeeld Stripe:**
- User doet betaling op Stripe Checkout
- Stripe verwerkt betaling (zou kunnen falen!)
- Stripe stuurt POST request naar JOUW webhook URL
- Jouw server update database, stuurt email, etc.
**Webhook = jouw HTTP endpoint** dat door externe service wordt aangeroepen.
**Cruciaal: signature verification**
Zonder verificatie kan iedereen je webhook URL aanroepen met fake data.
```typescript
// Stripe doet dit zo:
const sig = request.headers.get("stripe-signature");
const event = stripe.webhooks.constructEvent(
body, sig, process.env.STRIPE_WEBHOOK_SECRET
);
// Throws als signature niet klopt
```
---
## Slide 7: Wat we vandaag bouwen
### Subscription-dashboard
**Mini-app:** Premium subscription met Stripe + email confirmaties.
**Tech:**
- Next.js 16
- Stripe (test mode — geen echt geld)
- Resend (transactional email — gratis tier 3000/maand)
- ngrok of `localhost.run` voor lokaal webhook testen
**Flow:**
```
[Pricing page] → [Stripe Checkout] → [Webhook] → [Database + Email]
```
**Endpoints we bouwen:**
- `POST /api/checkout` — start Stripe sessie
- `POST /api/webhook/stripe` — ontvang Stripe events
- `POST /api/email` — verzend Resend mail (intern aangeroepen)
---
## Slide 8: LIVE DEMO 1 — Stripe Checkout
### ~25 min
**Wat ik laat zien:**
1. Stripe account opzetten (test mode)
2. Pricing page met "Subscribe" button
3. `app/api/checkout/route.ts` — maak Checkout session
```typescript
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const { priceId, email } = await req.json();
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: priceId, quantity: 1 }],
customer_email: email,
success_url: `${process.env.APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.APP_URL}/pricing`,
});
return Response.json({ url: session.url });
}
```
4. Client redirect naar `session.url`
5. Test in browser met Stripe test cards (4242 4242 4242 4242)
6. Stripe Dashboard — sessie zichtbaar, betaling staat als "complete"
---
## Slide 9: LIVE DEMO 2 — Webhook ontvangen
### ~25 min
**Wat ik laat zien:**
1. Webhook secret ophalen van Stripe Dashboard
2. `app/api/webhook/stripe/route.ts`:
```typescript
import { headers } from "next/headers";
export async function POST(req: Request) {
const body = await req.text(); // RAW body, niet json()
const sig = headers().get("stripe-signature")!;
let event;
try {
event = stripe.webhooks.constructEvent(
body, sig, process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
return new Response("Webhook signature failed", { status: 400 });
}
if (event.type === "checkout.session.completed") {
const session = event.data.object;
await supabase.from("subscriptions").insert({
customer_email: session.customer_email,
stripe_session_id: session.id,
status: "active",
});
}
return Response.json({ received: true });
}
```
3. **Lokaal testen:** `stripe listen --forward-to localhost:3000/api/webhook/stripe`
4. Stripe CLI geeft webhook secret terug — in `.env.local`
5. Doe een test-checkout — zie webhook binnenkomen in CLI én database update
**Belangrijk:** `req.text()` voor RAW body — niet `req.json()`. Signature wordt over bytes berekend.
---
## Slide 10: Pauze
### 15 min
---
## Slide 11: LIVE DEMO 3 — Resend email
### ~25 min
**Wat ik laat zien:**
1. Resend account, API key, eigen domain (of `onboarding@resend.dev` voor demo)
2. `pnpm add resend react-email`
3. Email template als React component:
```tsx
// emails/WelcomeEmail.tsx
export function WelcomeEmail({ name }: { name: string }) {
return (
<div style={{ fontFamily: "sans-serif" }}>
<h1>Welkom {name}!</h1>
<p>Bedankt voor je aanmelding bij Premium.</p>
</div>
);
}
```
4. Verzend in webhook handler:
```typescript
import { Resend } from "resend";
import { WelcomeEmail } from "@/emails/WelcomeEmail";
const resend = new Resend(process.env.RESEND_API_KEY!);
await resend.emails.send({
from: "onboarding@resend.dev",
to: session.customer_email!,
subject: "Welkom bij Premium!",
react: WelcomeEmail({ name: session.customer_email!.split("@")[0] }),
});
```
5. Test checkout → webhook → mail komt aan
6. Resend dashboard — email zichtbaar in logs, status delivered
---
## Slide 12: LIVE DEMO 4 — Rate limit + retry
### ~15 min
**Wat ik laat zien:**
**Probleem:** externe APIs kunnen falen. Wat doe je?
**Pattern 1 — Exponential backoff retry:**
```typescript
async function retry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (err) {
if (i === attempts - 1) throw err;
await new Promise(r => setTimeout(r, 2 ** i * 1000));
}
}
throw new Error("unreachable");
}
const data = await retry(() =>
fetch("https://api.example.com/data").then(r => r.json())
);
```
**Pattern 2 — Rate limit met `Upstash Ratelimit`:**
```typescript
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, "10s"),
});
// In API route
const { success } = await ratelimit.limit(userId);
if (!success) return new Response("Too many requests", { status: 429 });
```
Beide patronen zijn cruciaal voor productie. Niet doen = service crash bij verkeerd gedrag.
---
## Slide 13: Productie checklist
### Voordat je live gaat
**API keys:**
- ✓ Test-keys voor preview, prod-keys voor productie
- ✓ Vercel scoping per environment (Les 15)
- ✓ Rotate keys eens per kwartaal
**Webhooks:**
- ✓ Signature verification ALTIJD
- ✓ Idempotency — zelfde event 2x ontvangen = OK
- ✓ Log alle webhook payloads (helpt bij debug)
- ✓ Return 200 snel — verwerking async
**Errors:**
- ✓ Exponential backoff retry voor transient fails
- ✓ Rate limit eigen endpoints
- ✓ Monitor met Sentry / LogRocket / Vercel Analytics
**Security:**
- ✓ Keys nooit in client-bundle (geen `NEXT_PUBLIC_`)
- ✓ Webhook endpoints accepteren alleen verified signatures
- ✓ User-input validatie (Zod)
---
## Slide 14: Lesopdracht + Huiswerk
### Eigen externe API integreren
**Lesopdracht (30 min):**
- Stripe test account opzetten
- Pricing page + Checkout endpoint
- Doe een test-betaling met test card
- Geen webhook nog — alleen success_url demo
**Huiswerk (~2 uur, voor Les 18):**
- A: Webhook handler + signature verify
- B: Resend transactional email na betaling
- C: Retry logic voor externe API call
- D: `APIS.md` met:
- Welke externe APIs jouw app gebruikt
- Hoe webhooks getest worden (Stripe CLI of ngrok screenshots)
- Voorbeeld van een failed call + hoe afgevangen
- Eindopdracht-toepassing: welke API ga jij integreren
**Bonus:** OAuth login met GitHub/Google via Auth.js
---
## Slide 15: Volgende les + Afsluiting
### Vragen?
**Vandaag gezien:**
- Drie soorten externe API integratie
- OAuth flow basics
- Webhooks ontvangen + signature verify
- Stripe Checkout in productie-patroon
- Resend transactional email
- Rate limit + retry
- Productie checklist
**Volgende les (Les 18 — laatste!): Supabase Auth + RLS**
- Multi-user apps met login
- Magic link / password / social
- RLS policies — per-user data isolation
- Protected routes + middleware
- Combo van alles wat we geleerd hebben
**Vragen?**
---
## Slide Summary
| # | Title | Type |
|---|-------|------|
| 1 | Title | Opening |
| 2 | Terugblik | Recap |
| 3 | Planning | 180-min |
| 4 | 3 soorten API integratie | Theorie |
| 5 | OAuth basics | Theorie |
| 6 | Webhooks + verifying | Theorie |
| 7 | Wat we bouwen | Intro |
| 8 | **DEMO 1** — Stripe Checkout | Demo |
| 9 | **DEMO 2** — Webhook ontvangen | Demo |
| 10 | Pauze | Break |
| 11 | **DEMO 3** — Resend email | Demo |
| 12 | **DEMO 4** — Rate limit + retry | Demo |
| 13 | Productie checklist | Reflectie |
| 14 | Lesopdracht + Huiswerk | Praktijk |
| 15 | Afsluiting | Closing |
---
## Bronnen
- **Stripe Checkout docs:** https://docs.stripe.com/checkout
- **Stripe webhooks:** https://docs.stripe.com/webhooks
- **Stripe CLI:** https://docs.stripe.com/stripe-cli
- **Resend:** https://resend.com/docs
- **React Email:** https://react.email/
- **Auth.js:** https://authjs.dev/
- **Upstash Ratelimit:** https://upstash.com/docs/oss/sdks/ts/ratelimit
- **Better-Auth:** https://www.better-auth.com/

Binary file not shown.

Binary file not shown.

Binary file not shown.