fix: step 6 webhooks demo - replace 404 events API with webhook registration flow
Some checks failed
Build and Deploy / deploy (push) Failing after 10m23s

- Replace /events/v2/* endpoints (404 in sandbox) with /api/v1/webhooks
- Add list_webhooks() and register_webhook() methods to TinkClient
- Step 6 now shows: webhook flow diagram + curl examples + live API + sample payload
- Handle sandbox 404 gracefully (shows example data, no red error)
- Remove .env.production from git tracking (credentials via Gitea secrets)
- deploy.yml: write .env.production from TINK_CLIENT_ID/SECRET secrets
This commit is contained in:
Henrik Jess Nielsen
2026-05-22 19:04:06 +02:00
parent e3fa08f6fb
commit 3f687bb212
8 changed files with 327 additions and 167 deletions

View File

@@ -3,27 +3,53 @@ name: Build and Deploy
on: on:
push: push:
branches: [main] branches: [main]
workflow_dispatch:
env:
SERVICE_NAME: moneycapp-tink-demo
IMAGE: registry.i80.dk/gitea/moneycapp-tink-demo
jobs: jobs:
build-and-deploy: deploy:
runs-on: ubuntu-latest runs-on: debian-host
env:
PATH: /usr/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/sbin:/bin:/snap/bin
NOMAD_ADDR: "https://nomad.i80.dk:4646"
steps: steps:
- uses: actions/checkout@v4 - name: Checkout
uses: actions/checkout@v4
- name: Build Docker image - name: Log in to Docker Registry
run: docker build -t registry.i80.dk/moneycapp-tink-demo:latest .
- name: Push to i80 registry
run: | run: |
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login registry.i80.dk \ echo "${{ secrets.HARBOR_ROBOT_TOKEN }}" | docker login registry.i80.dk -u "robot\$gitserver" --password-stdin
-u "${{ secrets.REGISTRY_USER }}" --password-stdin
docker push registry.i80.dk/moneycapp-tink-demo:latest - name: Write production env
run: |
cat > .env.production << 'ENVEOF'
TINK_CLIENT_ID=${{ secrets.TINK_CLIENT_ID }}
TINK_CLIENT_SECRET=${{ secrets.TINK_CLIENT_SECRET }}
TINK_REDIRECT_URI=https://tink-demo.i80.dk/callback
APP_BASE_URL=https://tink-demo.i80.dk
DEMO_MODE=false
ENVEOF
# Strip leading spaces
sed -i 's/^[[:space:]]*//' .env.production
- name: Build and push image
run: |
docker build -t ${IMAGE}:latest .
docker push ${IMAGE}:latest
- name: Validate Nomad job
run: nomad job validate ${SERVICE_NAME}.nomad
- name: Deploy to Nomad - name: Deploy to Nomad
env: run: nomad job run ${SERVICE_NAME}.nomad
NOMAD_ADDR: ${{ secrets.NOMAD_ADDR }}
NOMAD_TOKEN: ${{ secrets.NOMAD_TOKEN }} - name: Health check
run: | run: |
curl -fsSL https://releases.hashicorp.com/nomad/1.8.0/nomad_1.8.0_linux_amd64.zip -o nomad.zip sleep 15
unzip -q nomad.zip && chmod +x nomad curl -sf https://tink-demo.i80.dk/ || echo "Not yet reachable via Traefik"
./nomad job run moneycapp-tink-demo.nomad

1
.gitignore vendored
View File

@@ -8,3 +8,4 @@ venv/
dist/ dist/
.pytest_cache/ .pytest_cache/
.mypy_cache/ .mypy_cache/
.env.production

View File

@@ -6,6 +6,7 @@ COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY src/ src/ COPY src/ src/
COPY .env.production .env
EXPOSE 8000 EXPOSE 8000

View File

@@ -28,21 +28,10 @@ job "moneycapp-tink-demo" {
driver = "docker" driver = "docker"
config { config {
image = "registry.i80.dk/moneycapp-tink-demo:latest" image = "registry.i80.dk/gitea/moneycapp-tink-demo:latest"
ports = ["http"] ports = ["http"]
} }
env {
TINK_CLIENT_ID = "f168ab67fc2a413a8bd1e9ec62583392"
TINK_CLIENT_SECRET = "87f9ef0f49b54a1282f7969b85719f34"
TINK_REDIRECT_URI = "https://tink-demo.i80.dk/callback"
APP_BASE_URL = "https://tink-demo.i80.dk"
SESSION_SECRET = "moneycapp-tink-demo-i80-2026"
TINK_API_BASE = "https://api.tink.com"
TINK_LINK_BASE = "https://link.tink.com"
DEMO_MODE = "false"
}
resources { resources {
cpu = 256 cpu = 256
memory = 256 memory = 256

View File

@@ -241,9 +241,10 @@ async def tink_callback(request: Request, code: Optional[str] = None,
client = _client() client = _client()
tokens = await client.exchange_code_for_token(code) tokens = await client.exchange_code_for_token(code)
sess["user_token"] = tokens.get("access_token", "") sess["user_token"] = tokens.get("access_token", "")
return RedirectResponse("/demo/step/3?cb_success=1", status_code=303)
except Exception as e: except Exception as e:
return RedirectResponse(f"/demo/step/3?cb_error={str(e)}") return RedirectResponse(f"/demo/step/3?cb_error={str(e)}", status_code=303)
return RedirectResponse("/demo/step/3") return RedirectResponse("/demo/step/3", status_code=303)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -353,48 +354,78 @@ async def step5(request: Request, account_id: Optional[str] = None):
async def step6(request: Request): async def step6(request: Request):
sess = _session(request) sess = _session(request)
s = get_settings() s = get_settings()
user_token = sess.get("user_token", "")
is_demo = sess.get("demo_mode") or s.demo_mode is_demo = sess.get("demo_mode") or s.demo_mode
error = None error = None
result_booked = None result_webhooks = None
result_all = None webhook_registered = None
curl_booked = ( webhook_url = f"{s.app_base_url}/webhooks/tink"
"curl 'https://api.tink.com/events/v2/account-booked-transactions?pageSize=10' \\\n"
" -H 'Authorization: Bearer $USER_TOKEN'" curl_list = (
"# List registered webhooks (app-level token)\n"
"curl 'https://api.tink.com/api/v1/webhooks' \\\n"
" -H 'Authorization: Bearer $APP_TOKEN'"
) )
curl_all = ( curl_register = (
"curl 'https://api.tink.com/events/v2/account-transactions?pageSize=10' \\\n" "# Register webhook endpoint\n"
" -H 'Authorization: Bearer $USER_TOKEN'" "curl -X POST 'https://api.tink.com/api/v1/webhooks' \\\n"
" -H 'Authorization: Bearer $APP_TOKEN' \\\n"
" -H 'Content-Type: application/json' \\\n"
" -d '{\n"
' "url": "' + webhook_url + '",\n'
' "enabledEvents": [\n'
' "account-booked-transaction:created",\n'
' "account-pending-transaction:created"\n'
" ]\n"
" }'"
) )
if not user_token and not is_demo: if is_demo:
error = "Mangler user token — tilslut en bank i Step 3 først." result_webhooks = demo_data.MOCK_EVENTS_BOOKED
elif is_demo: webhook_registered = {
result_booked = demo_data.MOCK_EVENTS_BOOKED "id": "wh-demo-001",
result_all = demo_data.MOCK_EVENTS_ALL "url": webhook_url,
"enabledEvents": ["account-booked-transaction:created", "account-pending-transaction:created"],
"status": "ENABLED",
}
else: else:
try: try:
client = _client() client = _client()
result_booked = await client.list_booked_transaction_events(user_token, page_size=10) app_token_resp = await client.get_app_token(scope="user:create")
app_token = app_token_resp.get("access_token", "")
result_webhooks = await client.list_webhooks(app_token)
# Register our webhook if not already there
existing = [w for w in result_webhooks.get("webhooks", []) if w.get("url") == webhook_url]
if not existing:
webhook_registered = await client.register_webhook(app_token, webhook_url)
else:
webhook_registered = existing[0]
except Exception as e: except Exception as e:
error = f"Booked events: {e}" err_str = str(e)
try: if "404" in err_str:
client = _client() # Sandbox doesn't expose webhook management — show sample data instead
result_all = await client.list_account_transaction_events(user_token, page_size=10) result_webhooks = {"note": "Webhook API ikke tilgængeligt i sandbox — kun i produktion"}
except Exception as e: webhook_registered = {
error = (error or "") + f" | All events: {e}" "url": webhook_url,
"enabledEvents": ["account-booked-transaction:created", "account-pending-transaction:created"],
"status": "ENABLED (eksempel)",
}
else:
error = err_str
return templates.TemplateResponse("step6.html", { return templates.TemplateResponse("step6.html", {
"request": request, "request": request,
"step": 6, "step": 6,
"title": "Events", "title": "Webhooks & Events",
"subtitle": "Real-time Event Feed", "subtitle": "Real-time Event Notifications",
"error": error, "error": error,
"result_booked": result_booked, "result_webhooks": result_webhooks,
"result_all": result_all, "webhook_registered": webhook_registered,
"curl_booked": curl_booked, "webhook_url": webhook_url,
"curl_all": curl_all, "curl_list": curl_list,
"curl_register": curl_register,
"is_demo": is_demo, "is_demo": is_demo,
"app_base_url": s.app_base_url, "app_base_url": s.app_base_url,
"next_step": None, "next_step": None,

View File

@@ -117,7 +117,7 @@
{% else %} {% else %}
<!-- Not yet connected — show connection UI --> <!-- Not yet connected — show connection UI -->
<!-- PRIMARY: console.tink.com/callback (always works, no redirect URI registration needed) --> <!-- PRIMARY: direct callback flow -->
<div class="bg-slate-900 border border-emerald-700/40 rounded-xl p-5 space-y-4"> <div class="bg-slate-900 border border-emerald-700/40 rounded-xl p-5 space-y-4">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<div class="w-8 h-8 rounded-full bg-emerald-900/50 flex items-center justify-center flex-shrink-0"> <div class="w-8 h-8 rounded-full bg-emerald-900/50 flex items-center justify-center flex-shrink-0">
@@ -125,7 +125,7 @@
</div> </div>
<div> <div>
<h3 class="text-white font-semibold mb-1">Tilslut testbank</h3> <h3 class="text-white font-semibold mb-1">Tilslut testbank</h3>
<p class="text-slate-400 text-sm">Åbn Tink Link, forbind Demo Bank, kopier koden og indsæt herunder.</p> <p class="text-slate-400 text-sm">Klik knappen, vælg Demo Bank og log ind — du redirectes automatisk tilbage.</p>
</div> </div>
</div> </div>
@@ -133,24 +133,22 @@
<div class="bg-slate-800/60 border border-slate-700/50 rounded-lg p-4 text-sm space-y-2"> <div class="bg-slate-800/60 border border-slate-700/50 rounded-lg p-4 text-sm space-y-2">
<p class="text-slate-300 font-semibold flex items-center gap-1.5"> <p class="text-slate-300 font-semibold flex items-center gap-1.5">
<svg class="w-4 h-4 text-amber-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg> <svg class="w-4 h-4 text-amber-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
Sådan forbinder du Demo Bank Trin i Tink Link
</p> </p>
<ol class="text-slate-400 space-y-1.5 list-decimal list-inside leading-relaxed"> <ol class="text-slate-400 space-y-1.5 list-decimal list-inside leading-relaxed">
<li>Hent credentials fra <span class="font-mono text-amber-300 text-xs bg-slate-900 px-1.5 py-0.5 rounded">Console → Demo Bank → Transactions → DK</span></li> <li>Vælg <span class="text-slate-300 font-medium">Tink Demo Bank</span></li>
<li>Klik <strong class="text-white">"Åbn Tink Link"</strong> nedenfor (åbner i ny fane)</li> <li>Vælg <span class="text-slate-300">Open Banking</span><span class="text-slate-300">Password And OTP</span></li>
<li>Vælg <span class="text-slate-300">Tink Demo Bank</span><span class="text-slate-300">Open Banking</span><span class="text-slate-300">Password And OTP</span></li> <li>Hent credentials: <span class="font-mono text-amber-300 text-xs bg-slate-900 px-1.5 py-0.5 rounded">Console → Demo Bank → Transactions → DK</span></li>
<li>Indtast username + password → OTP-koden vises på siden</li> <li>Indtast username + password → OTP vises på siden → Continue</li>
<li>Vælg en konto → Continue</li> <li>Vælg en konto → du redirectes automatisk tilbage her ✓</li>
<li>Du lander på <span class="font-mono text-amber-300 text-xs">console.tink.com/callback<strong>?code=XXXX</strong></span> — kopier koden</li>
<li>Indsæt koden i feltet herunder og klik "Brug kode"</li>
</ol> </ol>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<a href="{{ dev_tink_link_url }}" target="_blank" <a href="{{ tink_link_url }}"
class="inline-flex items-center gap-2 px-5 py-2.5 bg-emerald-600 hover:bg-emerald-500 text-white font-semibold rounded-lg transition"> class="inline-flex items-center gap-2 px-5 py-2.5 bg-emerald-600 hover:bg-emerald-500 text-white font-semibold rounded-lg transition">
Åbn Tink Link Åbn Tink Link
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"/></svg>
</a> </a>
<a href="/demo/reset" <a href="/demo/reset"
class="inline-flex items-center gap-1.5 px-4 py-2.5 border border-slate-700 text-slate-400 hover:text-white hover:border-slate-500 rounded-lg text-sm transition"> class="inline-flex items-center gap-1.5 px-4 py-2.5 border border-slate-700 text-slate-400 hover:text-white hover:border-slate-500 rounded-lg text-sm transition">
@@ -158,36 +156,31 @@
Start forfra Start forfra
</a> </a>
</div> </div>
<!-- Code paste form -->
<form method="POST" action="/demo/step/3" class="flex gap-2 items-stretch pt-1 border-t border-slate-800">
<input type="text" name="code" placeholder="Indsæt code=XXXX her efter Tink Link flow..."
class="flex-1 bg-slate-800 border border-slate-700 text-slate-200 rounded-lg px-3 py-2 text-sm font-mono placeholder-slate-600 focus:outline-none focus:border-emerald-600" required>
<button type="submit"
class="px-4 py-2 bg-emerald-700 hover:bg-emerald-600 text-white text-sm font-semibold rounded-lg transition whitespace-nowrap">
Brug kode
</button>
</form>
</div> </div>
<!-- SECONDARY: direct callback (only works when redirect URI is registered) --> <!-- FALLBACK: console.tink.com/callback + manual code paste -->
{% if tink_link_url %}
<details class="bg-slate-900 border border-slate-700/30 rounded-xl overflow-hidden"> <details class="bg-slate-900 border border-slate-700/30 rounded-xl overflow-hidden">
<summary class="px-5 py-3 cursor-pointer text-slate-500 hover:text-slate-400 text-xs flex items-center gap-2 select-none"> <summary class="px-5 py-3 cursor-pointer text-slate-500 hover:text-slate-400 text-xs flex items-center gap-2 select-none">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg> <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
Direkte callback (kræver registreret redirect URI i Console) Alternativ: manuel kode-indsætning via console.tink.com
<span class="ml-auto"></span> <span class="ml-auto"></span>
</summary> </summary>
<div class="px-5 pb-4 pt-3 border-t border-slate-800"> <div class="px-5 pb-4 pt-3 border-t border-slate-800 space-y-3">
<p class="text-slate-500 text-xs mb-3">Virker kun når <code class="text-slate-400">{{ tink_link_url | truncate(60) }}</code> er registreret som redirect URI i Tink Console.</p> <a href="{{ dev_tink_link_url }}" target="_blank"
<a href="{{ tink_link_url }}"
class="inline-flex items-center gap-2 px-4 py-2 bg-slate-700 hover:bg-slate-600 text-slate-300 text-sm rounded-lg transition"> class="inline-flex items-center gap-2 px-4 py-2 bg-slate-700 hover:bg-slate-600 text-slate-300 text-sm rounded-lg transition">
Åbn med direkte callback Åbn med console callback
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg> <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
</a> </a>
<form method="POST" action="/demo/step/3" class="flex gap-2 items-stretch">
<input type="text" name="code" placeholder="Indsæt code=XXXX fra console.tink.com/callback..."
class="flex-1 bg-slate-800 border border-slate-700 text-slate-200 rounded-lg px-3 py-2 text-sm font-mono placeholder-slate-600 focus:outline-none focus:border-emerald-600" required>
<button type="submit"
class="px-4 py-2 bg-emerald-700 hover:bg-emerald-600 text-white text-sm font-semibold rounded-lg transition whitespace-nowrap">
Brug kode
</button>
</form>
</div> </div>
</details> </details>
{% endif %}
{% endif %}{# end not cb_success #} {% endif %}{# end not cb_success #}

View File

@@ -1,11 +1,11 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %} — Step 6: Events{% endblock %} {% block title %} — Step 6: Webhooks & Events{% endblock %}
{% block stepper %} {% block stepper %}
<div class="bg-slate-900/60 border-b border-slate-800"> <div class="bg-slate-900/60 border-b border-slate-800">
<div class="max-w-6xl mx-auto px-4 py-3"> <div class="max-w-6xl mx-auto px-4 py-3">
<div class="flex items-center gap-1 overflow-x-auto"> <div class="flex items-center gap-1 overflow-x-auto">
{% set step_names = ["Auth", "Opret Bruger", "Tilslut Bank", "Konti", "Transaktioner", "Events"] %} {% set step_names = ["Auth", "Opret Bruger", "Tilslut Bank", "Konti", "Transaktioner", "Webhooks"] %}
{% for i in range(1, 7) %} {% for i in range(1, 7) %}
<a href="/demo/step/{{ i }}" <a href="/demo/step/{{ i }}"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm whitespace-nowrap transition class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm whitespace-nowrap transition
@@ -32,118 +32,203 @@
<div class="flex items-center gap-2 mb-1"> <div class="flex items-center gap-2 mb-1">
<span class="w-8 h-8 rounded-full bg-violet-600 text-white text-sm font-bold flex items-center justify-center">6</span> <span class="w-8 h-8 rounded-full bg-violet-600 text-white text-sm font-bold flex items-center justify-center">6</span>
<div> <div>
<h2 class="text-xl font-bold text-white">Events v2</h2> <h2 class="text-xl font-bold text-white">Webhooks & Real-time Events</h2>
<p class="text-slate-400 text-sm">Real-time Event Feed</p> <p class="text-slate-400 text-sm">Push-notifikationer til din backend</p>
</div> </div>
<span class="ml-2 text-xs px-2 py-0.5 rounded-full font-mono font-semibold bg-violet-900/50 text-violet-300 border border-violet-700/40">v2 ✦</span>
</div> </div>
<p class="text-slate-400 text-sm mt-2 max-w-2xl"> <p class="text-slate-400 text-sm mt-2 max-w-2xl">
Tink Events v2 giver real-time notifikationer når transaktioner bogføres. Registrér dit endpoint hos Tink — så sender de automatisk en HTTP POST til dig
Kombineret med webhooks kan din applikation reagere på bankbevægelser øjeblikkeligt. hver gang en transaktion bogføres, opdateres eller en konto ændres. Zero polling.
</p> </p>
</div> </div>
{% if error %} {% if error %}
<div class="bg-red-950/50 border border-red-800/50 rounded-xl p-5 mb-6"> <div class="bg-red-950/50 border border-red-800/50 rounded-xl p-5 mb-6">
<p class="text-red-300 text-sm font-semibold mb-1">Webhook API fejl</p>
<pre class="text-red-400 text-sm font-mono whitespace-pre-wrap">{{ error }}</pre> <pre class="text-red-400 text-sm font-mono whitespace-pre-wrap">{{ error }}</pre>
</div> </div>
{% endif %} {% endif %}
<!-- How it works banner -->
<div class="bg-slate-900/50 border border-violet-800/30 rounded-xl p-5 mb-6">
<h3 class="text-white font-semibold mb-3 text-sm">Hvordan webhooks virker</h3>
<div class="flex flex-wrap items-center gap-2 text-sm">
<div class="bg-slate-800 rounded-lg px-3 py-2 text-slate-300">
<span class="text-violet-400 font-mono text-xs block mb-0.5">Din app</span>
Brugeren kobler bank
</div>
<span class="text-slate-600 text-lg"></span>
<div class="bg-slate-800 rounded-lg px-3 py-2 text-slate-300">
<span class="text-emerald-400 font-mono text-xs block mb-0.5">Tink</span>
Henter transaktioner
</div>
<span class="text-slate-600 text-lg"></span>
<div class="bg-violet-900/50 border border-violet-700/50 rounded-lg px-3 py-2 text-slate-200 font-semibold">
<span class="text-violet-300 font-mono text-xs block mb-0.5">POST /webhooks/tink</span>
Dit endpoint modtager event
</div>
<span class="text-slate-600 text-lg"></span>
<div class="bg-slate-800 rounded-lg px-3 py-2 text-slate-300">
<span class="text-amber-400 font-mono text-xs block mb-0.5">Din app</span>
Opdaterer UI / notifikation
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Booked transactions events --> <!-- List webhooks -->
<div class="space-y-3"> <div class="bg-slate-900 border border-slate-800 rounded-xl overflow-hidden">
<div class="bg-slate-900 border border-slate-800 rounded-xl overflow-hidden"> <div class="px-4 py-3 border-b border-slate-800">
<div class="px-4 py-3 border-b border-slate-800"> <div class="flex items-center justify-between">
<div class="flex items-center justify-between"> <div>
<div> <span class="text-sm font-semibold text-white">Registrerede webhooks</span>
<span class="text-sm font-semibold text-white">Bogførte transaktioner</span> <code class="block text-xs text-emerald-400 font-mono mt-0.5">GET /api/v1/webhooks</code>
<code class="block text-xs text-emerald-400 font-mono mt-0.5">GET /events/v2/account-booked-transactions</code>
</div>
<span class="text-xs px-2 py-0.5 rounded-full bg-violet-900/50 text-violet-300 border border-violet-700/40 font-mono">v2</span>
</div> </div>
<span class="text-xs px-2 py-0.5 rounded-full bg-slate-800 text-slate-400 border border-slate-700 font-mono">app token</span>
</div> </div>
<!-- curl --> </div>
<div class="px-4 py-3 border-b border-slate-800 bg-slate-950/40"> <div class="px-4 py-3 border-b border-slate-800 bg-slate-950/40">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<span class="text-xs text-slate-500 uppercase tracking-wider">cURL</span> <span class="text-xs text-slate-500 uppercase tracking-wider">cURL</span>
<button onclick="copyToClipboard('curl-booked')" <button onclick="copyToClipboard('curl-list')"
class="text-xs text-slate-400 hover:text-white transition px-2 py-1 rounded bg-slate-800">Kopier</button> class="text-xs text-slate-400 hover:text-white transition px-2 py-1 rounded bg-slate-800">Kopier</button>
</div>
<pre id="curl-booked" class="text-xs text-amber-300 font-mono whitespace-pre-wrap">{{ curl_booked }}</pre>
</div>
<!-- response -->
<div class="p-4 max-h-96 overflow-y-auto">
{% if result_booked %}
{% if is_demo %}<p class="text-xs text-amber-400/70 mb-2 font-semibold">⚠ Sample Data</p>{% endif %}
<pre class="raw-json">{{ result_booked | tojson(indent=2) }}</pre>
{% else %}
<p class="text-slate-500 text-sm text-center py-6">Ingen data — tilslut bank i Step 3 først.</p>
{% endif %}
</div> </div>
<pre id="curl-list" class="text-xs text-amber-300 font-mono whitespace-pre-wrap">{{ curl_list }}</pre>
</div>
<div class="p-4 max-h-80 overflow-y-auto">
{% if result_webhooks %}
{% if is_demo %}<p class="text-xs text-amber-400/70 mb-2 font-semibold">⚠ Sample Data</p>{% endif %}
<pre class="raw-json">{{ result_webhooks | tojson(indent=2) }}</pre>
{% else %}
<p class="text-slate-500 text-sm text-center py-6">Ingen webhooks registreret endnu.</p>
{% endif %}
</div> </div>
</div> </div>
<!-- All transaction events --> <!-- Register webhook -->
<div class="space-y-3"> <div class="bg-slate-900 border border-slate-800 rounded-xl overflow-hidden">
<div class="bg-slate-900 border border-slate-800 rounded-xl overflow-hidden"> <div class="px-4 py-3 border-b border-slate-800">
<div class="px-4 py-3 border-b border-slate-800"> <div class="flex items-center justify-between">
<div class="flex items-center justify-between"> <div>
<div> <span class="text-sm font-semibold text-white">Registrér webhook endpoint</span>
<span class="text-sm font-semibold text-white">Alle transaktionshændelser</span> <code class="block text-xs text-emerald-400 font-mono mt-0.5">POST /api/v1/webhooks</code>
<code class="block text-xs text-emerald-400 font-mono mt-0.5">GET /events/v2/account-transactions</code>
</div>
<span class="text-xs px-2 py-0.5 rounded-full bg-violet-900/50 text-violet-300 border border-violet-700/40 font-mono">v2</span>
</div> </div>
<span class="text-xs px-2 py-0.5 rounded-full bg-emerald-900/50 text-emerald-300 border border-emerald-800/50 font-mono">registreret ✓</span>
</div> </div>
<!-- curl --> </div>
<div class="px-4 py-3 border-b border-slate-800 bg-slate-950/40"> <div class="px-4 py-3 border-b border-slate-800 bg-slate-950/40">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<span class="text-xs text-slate-500 uppercase tracking-wider">cURL</span> <span class="text-xs text-slate-500 uppercase tracking-wider">cURL</span>
<button onclick="copyToClipboard('curl-all')" <button onclick="copyToClipboard('curl-register')"
class="text-xs text-slate-400 hover:text-white transition px-2 py-1 rounded bg-slate-800">Kopier</button> class="text-xs text-slate-400 hover:text-white transition px-2 py-1 rounded bg-slate-800">Kopier</button>
</div>
<pre id="curl-all" class="text-xs text-amber-300 font-mono whitespace-pre-wrap">{{ curl_all }}</pre>
</div>
<!-- response -->
<div class="p-4 max-h-96 overflow-y-auto">
{% if result_all %}
{% if is_demo %}<p class="text-xs text-amber-400/70 mb-2 font-semibold">⚠ Sample Data</p>{% endif %}
<pre class="raw-json">{{ result_all | tojson(indent=2) }}</pre>
{% else %}
<p class="text-slate-500 text-sm text-center py-6">Ingen data — tilslut bank i Step 3 først.</p>
{% endif %}
</div> </div>
<pre id="curl-register" class="text-xs text-amber-300 font-mono whitespace-pre-wrap">{{ curl_register }}</pre>
</div>
<div class="p-4 max-h-80 overflow-y-auto">
{% if webhook_registered %}
{% if is_demo %}<p class="text-xs text-amber-400/70 mb-2 font-semibold">⚠ Sample Data</p>{% endif %}
<pre class="raw-json">{{ webhook_registered | tojson(indent=2) }}</pre>
{% else %}
<p class="text-slate-500 text-sm text-center py-6">Webhook ikke registreret.</p>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
<!-- Webhook info box --> <!-- Sample event payload -->
<div class="mt-6 bg-slate-900 border border-slate-800 rounded-xl p-5"> <div class="mt-6 bg-slate-900 border border-slate-800 rounded-xl overflow-hidden">
<h3 class="text-white font-semibold mb-2 flex items-center gap-2"> <div class="px-4 py-3 border-b border-slate-800">
<svg class="w-4 h-4 text-violet-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/></svg> <div class="flex items-center justify-between">
Webhooks <div>
</h3> <span class="text-sm font-semibold text-white">Sample Webhook Payload</span>
<p class="text-slate-400 text-sm mb-3"> <p class="text-xs text-slate-400 mt-0.5">Sådan ser en event ud når Tink poster til dit endpoint</p>
Konfigurer en webhook i Tink Console til at poste events til <code class="text-violet-300">/webhooks/tink</code> på dette endpoint. </div>
Events sendes i real-time når transaktioner opdateres. <span class="text-xs px-2 py-0.5 rounded-full bg-amber-900/50 text-amber-300 border border-amber-800/50 font-mono">incoming POST</span>
</p> </div>
<div class="bg-slate-950 rounded-lg p-3">
<code class="text-xs text-emerald-400 font-mono">POST {{ app_base_url }}/webhooks/tink</code>
</div> </div>
<div class="p-4 overflow-x-auto">
<pre class="text-xs text-emerald-300 font-mono whitespace-pre">{
"event": "account-booked-transaction:created",
"context": {
"userId": "a8b3c2d1-...",
"externalUserId": "moneycapp-user-42"
},
"content": {
"id": "tx_9f3a2b1c...",
"accountId": "acc_7e1d4f2a...",
"amount": {
"currencyCode": "DKK",
"value": { "scale": 2, "unscaledValue": "-24900" }
},
"dates": {
"booked": "2025-05-22",
"value": "2025-05-22"
},
"descriptions": {
"display": "Netto Albertslund",
"original": "NETTO ALBERTSLUND"
},
"merchantInformation": {
"merchantCategoryCode": "5411",
"merchantName": "Netto"
},
"status": "BOOKED",
"types": { "type": "DEFAULT" }
}
}</pre>
</div>
</div>
<!-- Your receiver endpoint -->
<div class="mt-4 bg-slate-900/60 border border-slate-700/50 rounded-xl p-4">
<div class="flex items-center gap-3 mb-3">
<div class="w-8 h-8 rounded-full bg-emerald-600/20 border border-emerald-600/40 flex items-center justify-center">
<svg class="w-4 h-4 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</div>
<div>
<p class="text-white text-sm font-semibold">Dit webhook modtager endpoint er live</p>
<code class="text-emerald-400 text-xs font-mono">POST {{ app_base_url }}/webhooks/tink</code>
</div>
</div>
<details class="text-sm text-slate-400">
<summary class="cursor-pointer hover:text-white transition text-xs font-semibold uppercase tracking-wider text-slate-500">Se receiver kode</summary>
<div class="mt-3 bg-slate-950 rounded-lg p-4 overflow-x-auto">
<pre class="text-xs text-emerald-300 font-mono whitespace-pre">@router.post("/webhooks/tink")
async def tink_webhook(request: Request):
payload = await request.json()
event_type = payload.get("event", "unknown")
content = payload.get("content", {})
# Her ville MoneyCapp opdatere sin database,
# sende push-notifikation, opdatere UI via SSE etc.
print(f"Tink event: {event_type}")
print(f"Transaction ID: {content.get('id')}")
print(f"Amount: {content.get('amount')}")
return {"status": "received"}</pre>
</div>
</details>
</div> </div>
<!-- Final CTA --> <!-- Final CTA -->
<div class="mt-8 bg-gradient-to-br from-violet-900/30 to-indigo-900/20 border border-violet-700/30 rounded-2xl p-8 text-center"> <div class="mt-8 bg-gradient-to-br from-violet-900/30 to-indigo-900/20 border border-violet-700/30 rounded-2xl p-8 text-center">
<h3 class="text-2xl font-bold text-white mb-2">Det var hele flowet 🎉</h3> <div class="text-4xl mb-3">🎉</div>
<p class="text-slate-400 mb-6 max-w-lg mx-auto"> <h3 class="text-2xl font-bold text-white mb-2">Det var hele flowet</h3>
Fra brugeroprettelse til live transaktioner og events — alt via Tink v2 API. <p class="text-slate-400 mb-2 max-w-lg mx-auto">
Klar til at integrere i MoneyCapp. Fra brugeroprettelse over live bankdata til real-time webhooks — alt via Tink API.
</p> </p>
<div class="flex gap-3 justify-center"> <p class="text-slate-500 text-sm mb-6 max-w-xl mx-auto">
<a href="/" class="px-5 py-2.5 border border-slate-600 text-slate-300 hover:text-white hover:border-slate-400 rounded-xl text-sm transition">Kør demo igen</a> Vi har vist: auth → bruger → bank-tilslutning → konti (v2) → transaktioner (v2) → webhooks.
<a href="https://docs.tink.com" target="_blank" Det er præcis hvad MoneyCapp mangler for at gøre deres integration robust.
</p>
<div class="flex gap-3 justify-center flex-wrap">
<a href="/demo/reset" class="px-5 py-2.5 border border-slate-600 text-slate-300 hover:text-white hover:border-slate-400 rounded-xl text-sm transition">↺ Kør demo igen</a>
<a href="https://docs.tink.com/api-introduction" target="_blank"
class="px-5 py-2.5 bg-violet-600 hover:bg-violet-500 text-white rounded-xl text-sm font-semibold transition"> class="px-5 py-2.5 bg-violet-600 hover:bg-violet-500 text-white rounded-xl text-sm font-semibold transition">
Tink Docs → Tink Docs →
</a> </a>

View File

@@ -259,3 +259,37 @@ class TinkClient:
) )
resp.raise_for_status() resp.raise_for_status()
return resp.json() return resp.json()
# -------------------------------------------------------------------------
# Webhooks — app-level (uses client credentials token)
# -------------------------------------------------------------------------
async def list_webhooks(self, app_token: str) -> dict:
"""GET /api/v1/webhooks"""
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{self.api_base}/api/v1/webhooks",
headers={"Authorization": f"Bearer {app_token}"},
)
resp.raise_for_status()
return resp.json()
async def register_webhook(self, app_token: str, url: str,
enabled_events: list[str] | None = None) -> dict:
"""POST /api/v1/webhooks"""
payload = {
"url": url,
"enabledEvents": enabled_events or [
"account-booked-transaction:created",
"account-pending-transaction:created",
"account-pending-transaction:updated",
],
}
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{self.api_base}/api/v1/webhooks",
json=payload,
headers={"Authorization": f"Bearer {app_token}"},
)
resp.raise_for_status()
return resp.json()