docs: developer documentation & customer-facing polish
All checks were successful
Build and Deploy / deploy (push) Successful in 45s

Code documentation:
- client.py: docstrings with Tink API docs URLs on every method
- demo.py: docstrings on all route handlers explaining Tink flow context
- webhook receiver: C# HMAC-SHA256 signature verification example

Customer-facing cleanup:
- Removed 'sales demo' / 'MoneyCapp × Tink' internal branding
- Neutral footer, consistent terminology (external_user_id, not tink_external_ref)
- Sandbox note on Step 3: anonymous flow vs production authorization_code flow
- Step 6: 'Next Steps for C#/.NET implementation' section
- demo_data.py: dynamic relative dates (no hardcoded year)
- print() → logging.getLogger, /debug-session gated behind DEMO_MODE
- Step 1 always resets session state (fresh start on every visit)
- README: neutral/collaborative tone, what-it-is-not section

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Henrik Jess Nielsen
2026-05-23 02:08:27 +02:00
parent bf61790465
commit f13eb21bb6
9 changed files with 238 additions and 100 deletions

View File

@@ -1,60 +1,52 @@
# MoneyCapp × Tink API Demo
# Tink API Demo
"Sales-y Swagger" — step-for-step gennemgang af Tink integrationsflowet med live JSON responses.
En simpel Python-demo der viser Tink open banking-flowet fra ende til anden — bygget som proof-of-concept til at illustrere hvordan integrationen kan se ud i praksis.
## Hvad det er
Formålet er ikke at erstatte en produktion C#/Umbraco-implementation, men at vise at Tink-flowet er veldokumenteret og relativt ligetil at bygge — uanset platform.
En hosted demo-app der viser hele Tink onboarding-flowet:
## Hvad demo'en viser
| Step | Endpoint | Version |
|------|----------|---------|
| 1 | POST `/api/v1/oauth/token` — Client Credentials | v1 |
| 2 | POST `/api/v1/user/create` — Opret bruger | v1 |
| 3 | Tink Link redirect — Tilslut bank | Link v1 |
| 4 | GET `/data/v2/accounts` — Konti med balances | **v2** |
| 5 | GET `/data/v2/transactions` — Transaktioner | **v2** |
| 6 | GET `/events/v2/account-transactions` + webhooks | **v2** |
| Step | Hvad sker der | Tink endpoint |
|------|---------------|---------------|
| 1 | Hent app token (client credentials) | `POST /api/v1/oauth/token` |
| 2 | Opret kunde i Tink med ekstern reference | `POST /api/v1/user/create` |
| 3 | Åbn Tink Link — brugeren tilslutter sin bank | Tink Link v1 |
| 4 | Hent brugerens konti med balances | `GET /data/v2/accounts` |
| 5 | Hent transaktioner | `GET /data/v2/transactions` |
| 6 | Webhook-registrering til real-time notifikationer | `POST /events/v2/webhook-endpoints` |
## Quick start (lokal dev)
Live JSON responses på hvert trin — præcis som det vil se ud i en reel integration.
```bash
cp .env.example .env
# Udfyld TINK_CLIENT_ID og TINK_CLIENT_SECRET fra Tink Console
# Tilføj http://localhost:8000/callback som Redirect URI i Tink Console
## Kør det selv
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
uvicorn src.main:app --reload
# Åbn http://localhost:8000
```
## Docker
```bash
docker compose up
```
## Deploy til i80/Nomad
> **Kun relevant for i80-infrastruktur.** For din egen infra: byg Docker image og kør med env vars.
1. Læg credentials i Consul KV:
```bash
consul kv put tink-demo/TINK_CLIENT_ID <din_client_id>
consul kv put tink-demo/TINK_CLIENT_SECRET <din_client_secret>
```
2. Tilføj `https://tink-demo.i80.dk/callback` som Redirect URI i Tink Console
3. Push til `main` → Gitea Actions bygger og deployer automatisk
## Docker (self-hosted)
Du skal bruge egne Tink sandbox-credentials fra [console.tink.com](https://console.tink.com).
```bash
cp .env.example .env
# Udfyld TINK_CLIENT_ID og TINK_CLIENT_SECRET
# Tilføj http://localhost:8000/callback som Redirect URI i Tink Console
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
make run
# Åbn http://localhost:8000
```
Eller med Docker:
```bash
cp .env.example .env
docker compose up
```
1. Gå til [console.tink.com](https://console.tink.com)
2. Opret en app → kopiér Client ID + Secret til `.env`
3. Under **Redirect URIs**: tilføj din callback URL
4. Under **Scopes**: aktiver `accounts:read`, `transactions:read`, `credentials:read/write`, `user:create`
## Tink Console opsætning
1. Gå til [console.tink.com](https://console.tink.com) → opret en app
2. Kopiér **Client ID** og **Client Secret** til `.env`
3. Under **Redirect URIs**: tilføj `http://localhost:8000/callback`
4. Under **Scopes**: aktiver `accounts:read`, `transactions:read`, `credentials:read`, `user:create`
## Hvad det ikke er
Dette er en demo — ikke produktionskode. Der er ingen database, ingen brugerstyring og tokens lever kun i hukommelsen. En reel implementation vil naturligvis bygge videre på jeres eksisterende platform og arkitektur.

View File

@@ -5,6 +5,12 @@ Used when DEMO_MODE=true or when no bank is connected.
Data mimics real Tink API v2 responses for a Danish user.
"""
from datetime import date, timedelta
_today = date.today()
_d = lambda offset=0: (_today + timedelta(days=offset)).isoformat()
_dt = lambda offset=0: f"{_d(offset)}T08:00:00Z"
MOCK_CREDENTIALS = {
"credentials": [
{
@@ -12,8 +18,8 @@ MOCK_CREDENTIALS = {
"providerName": "dk-demobank-open-banking-embedded",
"type": "PASSWORD",
"status": "UPDATED",
"statusUpdated": "2026-05-22T08:00:00Z",
"updated": "2026-05-22T08:00:00Z",
"statusUpdated": _dt(),
"updated": _dt(),
"fields": {"username": "demo_user_001"},
}
]
@@ -37,7 +43,7 @@ MOCK_ACCOUNTS = {
"iban": {"iban": "DK5000400440116243"},
"financialInstitution": {"accountNumber": "0440116243"},
},
"dates": {"lastRefreshed": "2026-05-22T08:00:00Z"},
"dates": {"lastRefreshed": _dt()},
"financialInstitutionId": "dk-demobank",
},
{
@@ -53,7 +59,7 @@ MOCK_ACCOUNTS = {
"iban": {"iban": "DK5000400440116244"},
"financialInstitution": {"accountNumber": "0440116244"},
},
"dates": {"lastRefreshed": "2026-05-22T08:00:00Z"},
"dates": {"lastRefreshed": _dt()},
"financialInstitutionId": "dk-demobank",
},
],
@@ -66,7 +72,7 @@ MOCK_TRANSACTIONS = {
"id": "tx-001-demo",
"accountId": "acc-8f3a2e1b-demo",
"amount": {"value": {"unscaledValue": "-89900", "scale": "2"}, "currencyCode": "DKK"},
"dates": {"booked": "2026-05-22", "value": "2026-05-22"},
"dates": {"booked": _d(), "value": _d()},
"description": "Spotify AB",
"status": "BOOKED",
"categories": {"pfm": {"id": "expenses.entertainment.streaming", "name": "Streaming"}},
@@ -77,7 +83,7 @@ MOCK_TRANSACTIONS = {
"id": "tx-002-demo",
"accountId": "acc-8f3a2e1b-demo",
"amount": {"value": {"unscaledValue": "-45000", "scale": "2"}, "currencyCode": "DKK"},
"dates": {"booked": "2026-05-21", "value": "2026-05-21"},
"dates": {"booked": _d(-1), "value": _d(-1)},
"description": "Netto Supermarked",
"status": "BOOKED",
"categories": {"pfm": {"id": "expenses.food.groceries", "name": "Dagligvarer"}},
@@ -88,8 +94,8 @@ MOCK_TRANSACTIONS = {
"id": "tx-003-demo",
"accountId": "acc-8f3a2e1b-demo",
"amount": {"value": {"unscaledValue": "3500000", "scale": "2"}, "currencyCode": "DKK"},
"dates": {"booked": "2026-05-20", "value": "2026-05-20"},
"description": "Løn maj 2026",
"dates": {"booked": _d(-2), "value": _d(-2)},
"description": f"Løn {_today.strftime('%b %Y')}",
"status": "BOOKED",
"categories": {"pfm": {"id": "income.salary", "name": "Løn"}},
"types": {"type": "DEFAULT"},
@@ -98,7 +104,7 @@ MOCK_TRANSACTIONS = {
"id": "tx-004-demo",
"accountId": "acc-8f3a2e1b-demo",
"amount": {"value": {"unscaledValue": "-120000", "scale": "2"}, "currencyCode": "DKK"},
"dates": {"booked": "2026-05-19", "value": "2026-05-19"},
"dates": {"booked": _d(-3), "value": _d(-3)},
"description": "Matas Strøget",
"status": "BOOKED",
"categories": {"pfm": {"id": "expenses.personal.health", "name": "Helse & Skønhed"}},
@@ -109,7 +115,7 @@ MOCK_TRANSACTIONS = {
"id": "tx-005-demo",
"accountId": "acc-8f3a2e1b-demo",
"amount": {"value": {"unscaledValue": "-8500000", "scale": "2"}, "currencyCode": "DKK"},
"dates": {"booked": "2026-05-01", "value": "2026-05-01"},
"dates": {"booked": _d(-21), "value": _d(-21)},
"description": "Husleje maj",
"status": "BOOKED",
"categories": {"pfm": {"id": "expenses.housing.rent", "name": "Husleje"}},
@@ -119,7 +125,7 @@ MOCK_TRANSACTIONS = {
"id": "tx-006-demo",
"accountId": "acc-8f3a2e1b-demo",
"amount": {"value": {"unscaledValue": "-35000", "scale": "2"}, "currencyCode": "DKK"},
"dates": {"booked": "2026-05-18", "value": "2026-05-18"},
"dates": {"booked": _d(-4), "value": _d(-4)},
"description": "DSB Rejse",
"status": "BOOKED",
"categories": {"pfm": {"id": "expenses.transport.public", "name": "Offentlig transport"}},
@@ -130,7 +136,7 @@ MOCK_TRANSACTIONS = {
"id": "tx-007-demo",
"accountId": "acc-8f3a2e1b-demo",
"amount": {"value": {"unscaledValue": "-19900", "scale": "2"}, "currencyCode": "DKK"},
"dates": {"booked": "2026-05-17", "value": "2026-05-17"},
"dates": {"booked": _d(-5), "value": _d(-5)},
"description": "Netflix International",
"status": "BOOKED",
"categories": {"pfm": {"id": "expenses.entertainment.streaming", "name": "Streaming"}},
@@ -141,7 +147,7 @@ MOCK_TRANSACTIONS = {
"id": "tx-008-demo",
"accountId": "acc-8f3a2e1b-demo",
"amount": {"value": {"unscaledValue": "-62500", "scale": "2"}, "currencyCode": "DKK"},
"dates": {"booked": "2026-05-16", "value": "2026-05-16"},
"dates": {"booked": _d(-6), "value": _d(-6)},
"description": "Fakta Falkoner",
"status": "BOOKED",
"categories": {"pfm": {"id": "expenses.food.groceries", "name": "Dagligvarer"}},
@@ -152,7 +158,7 @@ MOCK_TRANSACTIONS = {
"id": "tx-009-demo",
"accountId": "acc-8f3a2e1b-demo",
"amount": {"value": {"unscaledValue": "-15000", "scale": "2"}, "currencyCode": "DKK"},
"dates": {"booked": "2026-05-23", "value": "2026-05-23"},
"dates": {"booked": _d(1), "value": _d(1)},
"description": "7-Eleven Nørreport",
"status": "PENDING",
"categories": {"pfm": {"id": "expenses.food.restaurants", "name": "Mad & Drikke"}},
@@ -169,26 +175,26 @@ MOCK_EVENTS_BOOKED = {
"id": "evt-booked-001",
"type": "account-booked-transaction",
"accountId": "acc-8f3a2e1b-demo",
"created": "2026-05-22T08:12:33Z",
"created": _dt(),
"transaction": {
"id": "tx-001-demo",
"description": "Spotify AB",
"amount": {"value": {"unscaledValue": "-89900", "scale": "2"}, "currencyCode": "DKK"},
"status": "BOOKED",
"dates": {"booked": "2026-05-22"},
"dates": {"booked": _d()},
},
},
{
"id": "evt-booked-002",
"type": "account-booked-transaction",
"accountId": "acc-8f3a2e1b-demo",
"created": "2026-05-21T14:22:10Z",
"created": _dt(-1),
"transaction": {
"id": "tx-002-demo",
"description": "Netto Supermarked",
"amount": {"value": {"unscaledValue": "-45000", "scale": "2"}, "currencyCode": "DKK"},
"status": "BOOKED",
"dates": {"booked": "2026-05-21"},
"dates": {"booked": _d(-1)},
},
},
],
@@ -201,26 +207,26 @@ MOCK_EVENTS_ALL = {
"id": "evt-pending-001",
"type": "account-transaction",
"accountId": "acc-8f3a2e1b-demo",
"created": "2026-05-23T11:05:44Z",
"created": _dt(1),
"transaction": {
"id": "tx-009-demo",
"description": "7-Eleven Nørreport",
"amount": {"value": {"unscaledValue": "-15000", "scale": "2"}, "currencyCode": "DKK"},
"status": "PENDING",
"dates": {"booked": "2026-05-23"},
"dates": {"booked": _d(1)},
},
},
{
"id": "evt-booked-001",
"type": "account-booked-transaction",
"accountId": "acc-8f3a2e1b-demo",
"created": "2026-05-22T08:12:33Z",
"created": _dt(),
"transaction": {
"id": "tx-001-demo",
"description": "Spotify AB",
"amount": {"value": {"unscaledValue": "-89900", "scale": "2"}, "currencyCode": "DKK"},
"status": "BOOKED",
"dates": {"booked": "2026-05-22"},
"dates": {"booked": _d()},
},
},
],

View File

@@ -13,7 +13,7 @@ settings = get_settings()
app = FastAPI(
title="Tink API Demo",
description="MoneyCapp × Tink — sales demo showing v2 API endpoints",
description="Tink Open Banking API — integration walkthrough covering auth, users, accounts (v2), transactions (v2) and webhooks.",
version="1.0.0",
)

View File

@@ -6,6 +6,9 @@ with the live JSON response and a curl example.
import json
import uuid
import secrets
import asyncio
import logging
import traceback
from typing import Optional
from fastapi import APIRouter, Request, Form, HTTPException
@@ -18,6 +21,7 @@ from src import demo_data
router = APIRouter()
templates = Jinja2Templates(directory="src/templates")
logger = logging.getLogger(__name__)
def _client(log_cb=None) -> TinkClient:
@@ -40,7 +44,6 @@ def _session(request: Request) -> dict:
# Server-side token store — keeps JWTs OUT of the session cookie
# (cookie limit is 4KB; two JWTs alone are ~1.3KB before base64 overhead)
# ---------------------------------------------------------------------------
import asyncio
_token_store: dict[str, dict] = {} # sid → {"app_token": str, "user_token": str}
_callback_locks: dict[str, asyncio.Lock] = {} # sid → Lock (prevents concurrent code exchange)
@@ -112,7 +115,10 @@ async def reset_demo(request: Request):
@router.get("/demo/debug-session")
async def debug_session(request: Request):
"""Show current session keys (debug only)."""
"""Session state inspector — only available when DEMO_MODE=true."""
s = get_settings()
if not s.demo_mode:
raise HTTPException(status_code=404)
sess = _session(request)
safe = {
k: (v[:20] + "" if isinstance(v, str) and len(v) > 20 else v)
@@ -130,7 +136,19 @@ async def debug_session(request: Request):
@router.get("/demo/step/1", response_class=HTMLResponse)
async def step1(request: Request):
sess = _session(request)
"""
Step 1: Client Credentials authentication.
Fetches an app-level token with scope 'user:create,authorization:grant'.
Docs: https://docs.tink.com/api#connectivity/oauth/create-an-oauth-token
"""
# Step 1 always starts a clean session — equivalent to reset
old_sid = request.session.get("demo", {}).get("sid", "")
if old_sid:
_token_store.pop(old_sid, None)
_callback_locks.pop(old_sid, None)
request.session.pop("demo", None)
sess = _session(request) # creates a fresh demo dict with a new sid
client = _client(log_cb=_logger(sess))
s = get_settings()
error = None
@@ -169,6 +187,10 @@ async def step1(request: Request):
@router.get("/demo/step/2", response_class=HTMLResponse)
async def step2_get(request: Request):
"""
Step 2 (GET): Render user creation form.
Shows existing user if already created in this session.
"""
sess = _session(request)
s = get_settings()
app_token = _load_token(sess, "app_token")
@@ -196,6 +218,12 @@ async def step2_get(request: Request):
async def step2_post(request: Request,
customer_name: str = Form(default=""),
market: str = Form(default="DK")):
"""
Step 2 (POST): Create a Tink user tied to a customer reference.
external_user_id is built from the customer name + random hex suffix to ensure uniqueness.
Tink returns a user_id (UUID) that is stored in session for subsequent calls.
Docs: https://docs.tink.com/api#user/create-user
"""
sess = _session(request)
s = get_settings()
app_token = _load_token(sess, "app_token")
@@ -259,6 +287,13 @@ CONSOLE_CALLBACK = "https://console.tink.com/callback"
@router.get("/demo/step/3", response_class=HTMLResponse)
async def step3_get(request: Request):
"""
Step 3: Bank connection via Tink Link.
Calls authorization-grant/delegate to get a one-time code, then builds the
Tink Link URL that redirects the user to select and log into their bank.
After successful bank login, Tink redirects back to /callback with an auth code.
Docs: https://docs.tink.com/resources/tink-link/tink-link-web-permanent-users
"""
sess = _session(request)
s = get_settings()
client = _client(log_cb=_logger(sess))
@@ -408,9 +443,9 @@ async def tink_callback(request: Request, code: Optional[str] = None,
credentials_id: Optional[str] = None):
"""Tink Link OAuth callback — exchange code for user token."""
sess = _session(request)
print(f"[CALLBACK] code={code!r} error={error!r} session_keys={list(sess.keys())}")
logger.info("Tink callback: code=%r error=%r", code, error)
if error:
print(f"[CALLBACK] Tink returned error: {error}")
logger.warning("Tink returned error: %s", error)
return RedirectResponse(f"/demo/step/3?error={error}")
if code:
sid = sess.get("sid", "unknown")
@@ -418,31 +453,29 @@ async def tink_callback(request: Request, code: Optional[str] = None,
_callback_locks[sid] = asyncio.Lock()
async with _callback_locks[sid]:
if _load_token(sess, "user_token"):
print(f"[CALLBACK] Already have user_token — skipping duplicate exchange")
logger.debug("Already have user_token — skipping duplicate code exchange")
return RedirectResponse("/demo/step/3?cb_success=1", status_code=303)
try:
s = get_settings()
print(f"[CALLBACK] Exchanging code, redirect_uri={s.tink_redirect_uri!r}")
logger.info("Exchanging code for token, redirect_uri=%r", s.tink_redirect_uri)
client = _client(log_cb=_logger(sess))
tokens = await client.exchange_code_for_token(
code, redirect_uri=s.tink_redirect_uri
)
print(f"[CALLBACK] Token response keys: {list(tokens.keys())}")
user_token = tokens.get("access_token", "")
if not user_token:
print(f"[CALLBACK] ERROR: access_token missing, got: {tokens}")
logger.error("Token exchange succeeded but access_token missing: %s", tokens)
return RedirectResponse(
f"/demo/step/3?cb_error=Token+exchange+ok+but+access_token+missing+in+response",
status_code=303,
)
_store_token(sess, "user_token", user_token)
print(f"[CALLBACK] SUCCESS — user_token saved ({len(user_token)} chars)")
logger.info("user_token saved (%d chars)", len(user_token))
return RedirectResponse("/demo/step/3?cb_success=1", status_code=303)
except Exception as e:
import traceback
print(f"[CALLBACK] EXCEPTION: {e}\n{traceback.format_exc()}")
logger.exception("Token exchange failed: %s", e)
return RedirectResponse(f"/demo/step/3?cb_error={str(e)}", status_code=303)
print(f"[CALLBACK] No code — bare redirect to step 3")
logger.debug("Callback with no code — redirecting to step 3")
return RedirectResponse("/demo/step/3", status_code=303)
@@ -452,6 +485,11 @@ async def tink_callback(request: Request, code: Optional[str] = None,
@router.get("/demo/step/4", response_class=HTMLResponse)
async def step4(request: Request):
"""
Step 4: List accounts (v2 endpoint).
Requires a user_token from the callback. Falls back to mock data if demo_mode is set.
Docs: https://docs.tink.com/api#account-service-v2/account/list-accounts
"""
sess = _session(request)
s = get_settings()
user_token = _load_token(sess, "user_token")
@@ -501,6 +539,11 @@ async def step4(request: Request):
@router.get("/demo/step/5", response_class=HTMLResponse)
async def step5(request: Request, account_id: Optional[str] = None):
"""
Step 5: List transactions (v2 endpoint), optionally filtered by account_id.
Uses cursor-based pagination via nextPageToken.
Docs: https://docs.tink.com/api#transaction-service-v2/transaction/list-transactions
"""
sess = _session(request)
s = get_settings()
user_token = _load_token(sess, "user_token")
@@ -549,6 +592,12 @@ async def step5(request: Request, account_id: Optional[str] = None):
@router.get("/demo/step/6", response_class=HTMLResponse)
async def step6(request: Request):
"""
Step 6: Webhooks — list existing and register a new endpoint.
Demonstrates real-time event delivery (account updates, new transactions).
Webhook receiver is at /webhooks/tink — logs payloads to api_log.
Docs: https://docs.tink.com/api#webhook/create-webhook-endpoint
"""
sess = _session(request)
s = get_settings()
is_demo = sess.get("demo_mode") or s.demo_mode
@@ -657,8 +706,20 @@ async def clear_log(request: Request):
@router.post("/webhooks/tink")
async def webhook_receiver(request: Request):
"""Receive Tink webhook events (configure URL in Tink Console)."""
"""
Receive Tink webhook events posted by Tink after bank data refresh.
IMPORTANT — In production, verify Tink's HMAC-SHA256 signature before processing:
Docs: https://docs.tink.com/api#webhook/webhook-endpoints
C# example:
var signature = Request.Headers["X-Tink-Signature"];
var isValid = VerifyHmacSha256(requestBody, signature, webhookSecret);
if (!isValid) return Unauthorized();
Store the event payload in your database and trigger business logic
(e.g., refresh account balances, notify the customer).
"""
body = await request.json()
# In production you'd verify the signature and store events
print(f"[WEBHOOK] {json.dumps(body, indent=2)}")
logger.info("Webhook received: %s", json.dumps(body)[:200])
return {"status": "received"}

View File

@@ -37,7 +37,7 @@
<div class="w-8 h-8 rounded-lg bg-violet-600 flex items-center justify-center text-white font-bold text-sm">T</div>
<div>
<span class="font-semibold text-white">Tink API Demo</span>
<span class="text-slate-400 text-sm ml-2">MoneyCapp × Tink</span>
<span class="text-slate-400 text-sm ml-2">Open Banking Demo</span>
</div>
</a>
<div class="flex items-center gap-3">
@@ -71,7 +71,7 @@
<!-- Footer -->
<footer class="border-t border-slate-800 text-center text-slate-500 text-xs py-4">
Tink API Demo &mdash; MoneyCapp sales prototype &mdash; i80.dk
Tink Open Banking API Demo
</footer>
<script>

View File

@@ -117,6 +117,19 @@
{% else %}
<!-- Not yet connected — show connection UI -->
<!-- Sandbox note: anonymous flow vs production -->
<div class="bg-amber-950/40 border border-amber-700/40 rounded-xl px-5 py-3.5 flex items-start gap-3 text-sm">
<svg class="w-4 h-4 text-amber-400 flex-shrink-0 mt-0.5" 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>
<div class="text-amber-200/80 leading-relaxed">
<span class="font-semibold text-amber-300">Sandbox-note:</span>
Dette demo bruger <em>anonymous Tink Link flow</em> (ingen <code class="font-mono text-xs">authorization_code</code> i URL'en) —
en sandbox-begrænsning. I produktion <strong>skal</strong> du kalde
<code class="font-mono text-xs">authorization-grant/delegate</code> og sende koden med til Tink Link,
så bank-forbindelsen bindes til den korrekte Tink-bruger.
<a href="https://docs.tink.com/resources/tink-link/tink-link-web-permanent-users" target="_blank" class="text-amber-400 underline hover:text-amber-300 ml-1">Docs ↗</a>
</div>
</div>
<!-- PRIMARY: direct callback flow -->
<div class="bg-slate-900 border border-emerald-700/40 rounded-xl p-5 space-y-4">
<div class="flex items-start gap-3">

View File

@@ -38,9 +38,11 @@
<span class="ml-2 text-xs px-2 py-0.5 rounded-full font-mono font-semibold bg-slate-800 text-slate-400 border border-slate-700">v1</span>
</div>
<p class="text-slate-400 text-sm mt-2 max-w-2xl">
Opret en Tink-bruger med en <code class="text-violet-300">tink_external_ref</code>
MoneyCapp's interne reference til kunden i Tink. Gemmes i jeres kundedatabase som <code class="text-violet-300">tink_external_ref</code> (ikke jeres interne <code class="text-slate-400">customer_id</code>).
Tink returnerer et <code class="text-violet-300">user_id</code> som bruges i efterfølgende kald.
Opret en Tink-bruger med et <code class="text-violet-300">external_user_id</code>
jeres interne reference til kunden (f.eks. et kundenummer eller UUID fra jeres database).
Gem dette som en kolonne i jeres kundedatabase (<code class="text-violet-300">tink_user_ref</code> el.lign.) —
det er ikke det samme som jeres interne <code class="text-slate-400">customer_id</code>.
Tink returnerer et <code class="text-violet-300">user_id</code> (UUID) der bruges i efterfølgende kald.
</p>
</div>
@@ -89,8 +91,8 @@
<div class="px-5 py-4 border-b border-slate-800">
<p class="text-sm font-semibold text-white">Hvem opretter vi?</p>
<p class="text-xs text-slate-400 mt-0.5">
<code class="text-violet-300">tink_external_ref</code> = jeres reference til kunden i Tink
adskilt fra jeres interne <code class="text-slate-400">customer_id</code>
<code class="text-violet-300">external_user_id</code> = jeres interne kundereference
gemmes i jeres database som f.eks. <code class="text-slate-400">tink_user_ref</code>
</p>
</div>
<form method="POST" action="/demo/step/2" class="p-5 space-y-4">
@@ -101,7 +103,7 @@
value="Henrik Jess"
class="w-full bg-slate-950 border border-slate-700 rounded-lg px-4 py-2.5 text-white text-sm font-mono
placeholder-slate-600 focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500/30 transition">
<p class="text-xs text-slate-600 mt-1.5"><code class="text-violet-300/70">tink_external_ref</code> = <code class="text-violet-300/70">moneycapp-henrik-jess-a3f9c1</code></p>
<p class="text-xs text-slate-600 mt-1.5"><code class="text-violet-300/70">external_user_id</code> = <code class="text-violet-300/70">moneycapp-henrik-jess-a3f9c1</code></p>
</div>
<div>
<label class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Marked</label>
@@ -136,13 +138,13 @@
<p class="text-xs text-slate-500 uppercase tracking-wider mb-3">API endpoint</p>
<code class="text-emerald-400 font-mono text-sm">POST https://api.tink.com/api/v1/user/create</code>
<div class="mt-3 bg-slate-950 rounded-lg p-3 overflow-x-auto">
<pre class="text-xs text-amber-300 font-mono whitespace-pre"># MoneyCapp DB:
# customer_id = 42 ← jeres interne ID (Tink ser det aldrig)
# tink_external_ref = "moneycapp-42-a3f9c1" ← Tink-reference
<pre class="text-xs text-amber-300 font-mono whitespace-pre"># Your DB schema (example):
# customer_id = 42 your internal ID (Tink never sees this)
# tink_user_ref = "moneycapp-42-a3f9c1" ← store Tink's external_user_id here
# Request body
{
"external_user_id": "moneycapp-&lt;ref&gt;", ← tink_external_ref
"external_user_id": "moneycapp-&lt;ref&gt;", ← your reference, stored in DB
"market": "DK",
"locale": "da_DK"
}

View File

@@ -223,8 +223,7 @@ async def tink_webhook(request: Request):
Fra brugeroprettelse over live bankdata til real-time webhooks — alt via Tink API.
</p>
<p class="text-slate-500 text-sm mb-6 max-w-xl mx-auto">
Vi har vist: auth → bruger → bank-tilslutning → konti (v2) → transaktioner (v2) → webhooks.
Det er præcis hvad MoneyCapp mangler for at gøre deres integration robust.
Auth → bruger → bank-tilslutning → konti (v2) → transaktioner (v2) → webhooks.
</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>
@@ -235,6 +234,33 @@ async def tink_webhook(request: Request):
</div>
</div>
<!-- Next Steps for implementation -->
<div class="mt-6 bg-slate-900/60 border border-slate-800 rounded-2xl p-7">
<h4 class="text-white font-semibold text-base mb-4 flex items-center gap-2">
<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="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>
Næste skridt mod en produktion-klar integration
</h4>
<div class="grid md:grid-cols-2 gap-3 text-sm text-slate-400">
<div class="bg-slate-800/60 rounded-lg p-4 space-y-1.5">
<p class="text-violet-300 font-semibold text-xs uppercase tracking-wider mb-2">Backend (C# / .NET)</p>
<p>1. Lav en <code class="text-violet-300 font-mono text-xs">TinkApiClient</code> wrapper (brug <code class="text-xs font-mono">tink/client.py</code> som reference)</p>
<p>2. Gem <code class="text-violet-300 font-mono text-xs">external_user_id</code> + <code class="text-violet-300 font-mono text-xs">user_id</code> i din kundedatabase</p>
<p>3. Implementér <code class="text-violet-300 font-mono text-xs">/callback</code> endpoint med token exchange</p>
<p>4. Gem tokens sikkert (encrypted, server-side — ikke i cookie)</p>
</div>
<div class="bg-slate-800/60 rounded-lg p-4 space-y-1.5">
<p class="text-violet-300 font-semibold text-xs uppercase tracking-wider mb-2">Webhooks & Production</p>
<p>5. Byg webhook receiver med <a href="https://docs.tink.com/api#webhook/webhook-endpoints" target="_blank" class="text-violet-400 hover:text-violet-300 underline">HMAC-SHA256 signature verification</a></p>
<p>6. Skift til production Tink-credentials (Tink Console)</p>
<p>7. Registrér din production callback URI i Tink Console</p>
<p>8. Brug <code class="text-violet-300 font-mono text-xs">authorization-grant/delegate</code> i prod-flowet (ikke anon)</p>
</div>
</div>
<p class="text-xs text-slate-600 mt-4">
Kildekoden til dette demo (<code class="font-mono">src/tink/client.py</code> og <code class="font-mono">src/routes/demo.py</code>) er skrevet for at være letlæselig og direkte overførbar til andre platforme.
</p>
</div>
<!-- Navigation -->
<div class="mt-4 flex justify-start">
<a href="/demo/step/5"

View File

@@ -1,6 +1,9 @@
"""
Tink API client — async httpx wrapper covering auth, users,
accounts (v2), transactions (v2), events (v2), and bank connectivity.
Tink API reference: https://docs.tink.com/api-introduction
Endpoint reference: https://docs.tink.com/api#overview
"""
import json
@@ -89,7 +92,10 @@ class TinkClient:
# -------------------------------------------------------------------------
async def get_app_token(self, scope: str = "user:create") -> dict:
"""Client credentials flow — returns app-level token."""
"""
Client credentials flow — returns app-level access token.
Docs: https://docs.tink.com/api#connectivity/oauth/create-an-oauth-token
"""
return await self._post(
f"{self.api_base}/api/v1/oauth/token",
headers={"Content-Type": "application/x-www-form-urlencoded"},
@@ -103,6 +109,10 @@ class TinkClient:
async def exchange_code_for_token(self, code: str,
redirect_uri: str | None = None) -> dict:
"""
Exchange the authorization_code from Tink Link callback for a user-scoped token.
Docs: https://docs.tink.com/api#connectivity/oauth/create-an-oauth-token
"""
data: dict = {
"client_id": self.client_id,
"client_secret": self.client_secret,
@@ -123,6 +133,11 @@ class TinkClient:
async def create_user(self, app_token: str, external_user_id: str,
market: str = "DK", locale: str = "da_DK") -> dict:
"""
Create a Tink user linked to your internal customer reference.
external_user_id = your system's reference (e.g. "moneycapp-42"). Tink returns a user_id.
Docs: https://docs.tink.com/api#user/create-user
"""
return await self._post(
f"{self.api_base}/api/v1/user/create",
headers={"Authorization": f"Bearer {app_token}"},
@@ -137,6 +152,11 @@ class TinkClient:
async def get_authorization_grant_token(self, app_token: str, user_id: str,
scope: str) -> dict:
"""
Delegate an authorization grant for a specific user — generates a one-time code
that binds the Tink Link session to that user.
Docs: https://docs.tink.com/api#connectivity/oauth/create-authorization
"""
return await self._post(
f"{self.api_base}/api/v1/oauth/authorization-grant/delegate",
headers={
@@ -153,6 +173,12 @@ class TinkClient:
def get_tink_link_url(self, market: str = "DK",
authorization_code: str | None = None,
redirect_uri_override: str | None = None) -> str:
"""
Build the Tink Link URL for bank connection.
With authorization_code: links the session to a specific Tink user (production flow).
Without: anonymous flow (sandbox only).
Docs: https://docs.tink.com/resources/tink-link/tink-link-web-permanent-users
"""
from urllib.parse import urlencode
params: dict = {
"client_id": self.client_id,
@@ -184,6 +210,10 @@ class TinkClient:
async def list_accounts(self, user_token: str, page_size: int = 50,
page_token: Optional[str] = None) -> dict:
"""
List all accounts for a user including balance and IBAN.
Docs: https://docs.tink.com/api#account-service-v2/account/list-accounts
"""
params: dict = {"pageSize": page_size}
if page_token:
params["pageToken"] = page_token
@@ -206,6 +236,10 @@ class TinkClient:
async def list_transactions(self, user_token: str, page_size: int = 25,
page_token: Optional[str] = None,
account_id: Optional[str] = None) -> dict:
"""
List transactions with cursor-based pagination.
Docs: https://docs.tink.com/api#transaction-service-v2/transaction/list-transactions
"""
params: dict = {"pageSize": page_size}
if page_token:
params["pageToken"] = page_token
@@ -265,6 +299,10 @@ class TinkClient:
async def register_webhook(self, app_token: str, url: str,
enabled_events: list[str] | None = None) -> dict:
"""
Register a webhook endpoint for real-time transaction notifications.
Docs: https://docs.tink.com/api#webhook/create-webhook-endpoint
"""
return await self._post(
f"{self.api_base}/api/v1/webhooks",
headers={"Authorization": f"Bearer {app_token}"},