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 | | Step | Hvad sker der | Tink endpoint |
|------|----------|---------| |------|---------------|---------------|
| 1 | POST `/api/v1/oauth/token` — Client Credentials | v1 | | 1 | Hent app token (client credentials) | `POST /api/v1/oauth/token` |
| 2 | POST `/api/v1/user/create` — Opret bruger | v1 | | 2 | Opret kunde i Tink med ekstern reference | `POST /api/v1/user/create` |
| 3 | Tink Link redirect — Tilslut bank | Link v1 | | 3 | Åbn Tink Link — brugeren tilslutter sin bank | Tink Link v1 |
| 4 | GET `/data/v2/accounts` — Konti med balances | **v2** | | 4 | Hent brugerens konti med balances | `GET /data/v2/accounts` |
| 5 | GET `/data/v2/transactions` — Transaktioner | **v2** | | 5 | Hent transaktioner | `GET /data/v2/transactions` |
| 6 | GET `/events/v2/account-transactions` + webhooks | **v2** | | 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 ## Kør det selv
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
python3 -m venv .venv && source .venv/bin/activate Du skal bruge egne Tink sandbox-credentials fra [console.tink.com](https://console.tink.com).
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)
```bash ```bash
cp .env.example .env cp .env.example .env
# Udfyld TINK_CLIENT_ID og TINK_CLIENT_SECRET # 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 docker compose up
``` ```
1. Gå til [console.tink.com](https://console.tink.com) ## Tink Console opsætning
2. Opret en app → kopiér Client ID + Secret til `.env`
3. Under **Redirect URIs**: tilføj din callback URL 1. Gå til [console.tink.com](https://console.tink.com) → opret en app
4. Under **Scopes**: aktiver `accounts:read`, `transactions:read`, `credentials:read/write`, `user:create` 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. 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 = { MOCK_CREDENTIALS = {
"credentials": [ "credentials": [
{ {
@@ -12,8 +18,8 @@ MOCK_CREDENTIALS = {
"providerName": "dk-demobank-open-banking-embedded", "providerName": "dk-demobank-open-banking-embedded",
"type": "PASSWORD", "type": "PASSWORD",
"status": "UPDATED", "status": "UPDATED",
"statusUpdated": "2026-05-22T08:00:00Z", "statusUpdated": _dt(),
"updated": "2026-05-22T08:00:00Z", "updated": _dt(),
"fields": {"username": "demo_user_001"}, "fields": {"username": "demo_user_001"},
} }
] ]
@@ -37,7 +43,7 @@ MOCK_ACCOUNTS = {
"iban": {"iban": "DK5000400440116243"}, "iban": {"iban": "DK5000400440116243"},
"financialInstitution": {"accountNumber": "0440116243"}, "financialInstitution": {"accountNumber": "0440116243"},
}, },
"dates": {"lastRefreshed": "2026-05-22T08:00:00Z"}, "dates": {"lastRefreshed": _dt()},
"financialInstitutionId": "dk-demobank", "financialInstitutionId": "dk-demobank",
}, },
{ {
@@ -53,7 +59,7 @@ MOCK_ACCOUNTS = {
"iban": {"iban": "DK5000400440116244"}, "iban": {"iban": "DK5000400440116244"},
"financialInstitution": {"accountNumber": "0440116244"}, "financialInstitution": {"accountNumber": "0440116244"},
}, },
"dates": {"lastRefreshed": "2026-05-22T08:00:00Z"}, "dates": {"lastRefreshed": _dt()},
"financialInstitutionId": "dk-demobank", "financialInstitutionId": "dk-demobank",
}, },
], ],
@@ -66,7 +72,7 @@ MOCK_TRANSACTIONS = {
"id": "tx-001-demo", "id": "tx-001-demo",
"accountId": "acc-8f3a2e1b-demo", "accountId": "acc-8f3a2e1b-demo",
"amount": {"value": {"unscaledValue": "-89900", "scale": "2"}, "currencyCode": "DKK"}, "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", "description": "Spotify AB",
"status": "BOOKED", "status": "BOOKED",
"categories": {"pfm": {"id": "expenses.entertainment.streaming", "name": "Streaming"}}, "categories": {"pfm": {"id": "expenses.entertainment.streaming", "name": "Streaming"}},
@@ -77,7 +83,7 @@ MOCK_TRANSACTIONS = {
"id": "tx-002-demo", "id": "tx-002-demo",
"accountId": "acc-8f3a2e1b-demo", "accountId": "acc-8f3a2e1b-demo",
"amount": {"value": {"unscaledValue": "-45000", "scale": "2"}, "currencyCode": "DKK"}, "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", "description": "Netto Supermarked",
"status": "BOOKED", "status": "BOOKED",
"categories": {"pfm": {"id": "expenses.food.groceries", "name": "Dagligvarer"}}, "categories": {"pfm": {"id": "expenses.food.groceries", "name": "Dagligvarer"}},
@@ -88,8 +94,8 @@ MOCK_TRANSACTIONS = {
"id": "tx-003-demo", "id": "tx-003-demo",
"accountId": "acc-8f3a2e1b-demo", "accountId": "acc-8f3a2e1b-demo",
"amount": {"value": {"unscaledValue": "3500000", "scale": "2"}, "currencyCode": "DKK"}, "amount": {"value": {"unscaledValue": "3500000", "scale": "2"}, "currencyCode": "DKK"},
"dates": {"booked": "2026-05-20", "value": "2026-05-20"}, "dates": {"booked": _d(-2), "value": _d(-2)},
"description": "Løn maj 2026", "description": f"Løn {_today.strftime('%b %Y')}",
"status": "BOOKED", "status": "BOOKED",
"categories": {"pfm": {"id": "income.salary", "name": "Løn"}}, "categories": {"pfm": {"id": "income.salary", "name": "Løn"}},
"types": {"type": "DEFAULT"}, "types": {"type": "DEFAULT"},
@@ -98,7 +104,7 @@ MOCK_TRANSACTIONS = {
"id": "tx-004-demo", "id": "tx-004-demo",
"accountId": "acc-8f3a2e1b-demo", "accountId": "acc-8f3a2e1b-demo",
"amount": {"value": {"unscaledValue": "-120000", "scale": "2"}, "currencyCode": "DKK"}, "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", "description": "Matas Strøget",
"status": "BOOKED", "status": "BOOKED",
"categories": {"pfm": {"id": "expenses.personal.health", "name": "Helse & Skønhed"}}, "categories": {"pfm": {"id": "expenses.personal.health", "name": "Helse & Skønhed"}},
@@ -109,7 +115,7 @@ MOCK_TRANSACTIONS = {
"id": "tx-005-demo", "id": "tx-005-demo",
"accountId": "acc-8f3a2e1b-demo", "accountId": "acc-8f3a2e1b-demo",
"amount": {"value": {"unscaledValue": "-8500000", "scale": "2"}, "currencyCode": "DKK"}, "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", "description": "Husleje maj",
"status": "BOOKED", "status": "BOOKED",
"categories": {"pfm": {"id": "expenses.housing.rent", "name": "Husleje"}}, "categories": {"pfm": {"id": "expenses.housing.rent", "name": "Husleje"}},
@@ -119,7 +125,7 @@ MOCK_TRANSACTIONS = {
"id": "tx-006-demo", "id": "tx-006-demo",
"accountId": "acc-8f3a2e1b-demo", "accountId": "acc-8f3a2e1b-demo",
"amount": {"value": {"unscaledValue": "-35000", "scale": "2"}, "currencyCode": "DKK"}, "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", "description": "DSB Rejse",
"status": "BOOKED", "status": "BOOKED",
"categories": {"pfm": {"id": "expenses.transport.public", "name": "Offentlig transport"}}, "categories": {"pfm": {"id": "expenses.transport.public", "name": "Offentlig transport"}},
@@ -130,7 +136,7 @@ MOCK_TRANSACTIONS = {
"id": "tx-007-demo", "id": "tx-007-demo",
"accountId": "acc-8f3a2e1b-demo", "accountId": "acc-8f3a2e1b-demo",
"amount": {"value": {"unscaledValue": "-19900", "scale": "2"}, "currencyCode": "DKK"}, "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", "description": "Netflix International",
"status": "BOOKED", "status": "BOOKED",
"categories": {"pfm": {"id": "expenses.entertainment.streaming", "name": "Streaming"}}, "categories": {"pfm": {"id": "expenses.entertainment.streaming", "name": "Streaming"}},
@@ -141,7 +147,7 @@ MOCK_TRANSACTIONS = {
"id": "tx-008-demo", "id": "tx-008-demo",
"accountId": "acc-8f3a2e1b-demo", "accountId": "acc-8f3a2e1b-demo",
"amount": {"value": {"unscaledValue": "-62500", "scale": "2"}, "currencyCode": "DKK"}, "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", "description": "Fakta Falkoner",
"status": "BOOKED", "status": "BOOKED",
"categories": {"pfm": {"id": "expenses.food.groceries", "name": "Dagligvarer"}}, "categories": {"pfm": {"id": "expenses.food.groceries", "name": "Dagligvarer"}},
@@ -152,7 +158,7 @@ MOCK_TRANSACTIONS = {
"id": "tx-009-demo", "id": "tx-009-demo",
"accountId": "acc-8f3a2e1b-demo", "accountId": "acc-8f3a2e1b-demo",
"amount": {"value": {"unscaledValue": "-15000", "scale": "2"}, "currencyCode": "DKK"}, "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", "description": "7-Eleven Nørreport",
"status": "PENDING", "status": "PENDING",
"categories": {"pfm": {"id": "expenses.food.restaurants", "name": "Mad & Drikke"}}, "categories": {"pfm": {"id": "expenses.food.restaurants", "name": "Mad & Drikke"}},
@@ -169,26 +175,26 @@ MOCK_EVENTS_BOOKED = {
"id": "evt-booked-001", "id": "evt-booked-001",
"type": "account-booked-transaction", "type": "account-booked-transaction",
"accountId": "acc-8f3a2e1b-demo", "accountId": "acc-8f3a2e1b-demo",
"created": "2026-05-22T08:12:33Z", "created": _dt(),
"transaction": { "transaction": {
"id": "tx-001-demo", "id": "tx-001-demo",
"description": "Spotify AB", "description": "Spotify AB",
"amount": {"value": {"unscaledValue": "-89900", "scale": "2"}, "currencyCode": "DKK"}, "amount": {"value": {"unscaledValue": "-89900", "scale": "2"}, "currencyCode": "DKK"},
"status": "BOOKED", "status": "BOOKED",
"dates": {"booked": "2026-05-22"}, "dates": {"booked": _d()},
}, },
}, },
{ {
"id": "evt-booked-002", "id": "evt-booked-002",
"type": "account-booked-transaction", "type": "account-booked-transaction",
"accountId": "acc-8f3a2e1b-demo", "accountId": "acc-8f3a2e1b-demo",
"created": "2026-05-21T14:22:10Z", "created": _dt(-1),
"transaction": { "transaction": {
"id": "tx-002-demo", "id": "tx-002-demo",
"description": "Netto Supermarked", "description": "Netto Supermarked",
"amount": {"value": {"unscaledValue": "-45000", "scale": "2"}, "currencyCode": "DKK"}, "amount": {"value": {"unscaledValue": "-45000", "scale": "2"}, "currencyCode": "DKK"},
"status": "BOOKED", "status": "BOOKED",
"dates": {"booked": "2026-05-21"}, "dates": {"booked": _d(-1)},
}, },
}, },
], ],
@@ -201,26 +207,26 @@ MOCK_EVENTS_ALL = {
"id": "evt-pending-001", "id": "evt-pending-001",
"type": "account-transaction", "type": "account-transaction",
"accountId": "acc-8f3a2e1b-demo", "accountId": "acc-8f3a2e1b-demo",
"created": "2026-05-23T11:05:44Z", "created": _dt(1),
"transaction": { "transaction": {
"id": "tx-009-demo", "id": "tx-009-demo",
"description": "7-Eleven Nørreport", "description": "7-Eleven Nørreport",
"amount": {"value": {"unscaledValue": "-15000", "scale": "2"}, "currencyCode": "DKK"}, "amount": {"value": {"unscaledValue": "-15000", "scale": "2"}, "currencyCode": "DKK"},
"status": "PENDING", "status": "PENDING",
"dates": {"booked": "2026-05-23"}, "dates": {"booked": _d(1)},
}, },
}, },
{ {
"id": "evt-booked-001", "id": "evt-booked-001",
"type": "account-booked-transaction", "type": "account-booked-transaction",
"accountId": "acc-8f3a2e1b-demo", "accountId": "acc-8f3a2e1b-demo",
"created": "2026-05-22T08:12:33Z", "created": _dt(),
"transaction": { "transaction": {
"id": "tx-001-demo", "id": "tx-001-demo",
"description": "Spotify AB", "description": "Spotify AB",
"amount": {"value": {"unscaledValue": "-89900", "scale": "2"}, "currencyCode": "DKK"}, "amount": {"value": {"unscaledValue": "-89900", "scale": "2"}, "currencyCode": "DKK"},
"status": "BOOKED", "status": "BOOKED",
"dates": {"booked": "2026-05-22"}, "dates": {"booked": _d()},
}, },
}, },
], ],

View File

@@ -13,7 +13,7 @@ settings = get_settings()
app = FastAPI( app = FastAPI(
title="Tink API Demo", 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", version="1.0.0",
) )

View File

@@ -6,6 +6,9 @@ with the live JSON response and a curl example.
import json import json
import uuid import uuid
import secrets import secrets
import asyncio
import logging
import traceback
from typing import Optional from typing import Optional
from fastapi import APIRouter, Request, Form, HTTPException from fastapi import APIRouter, Request, Form, HTTPException
@@ -18,6 +21,7 @@ from src import demo_data
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="src/templates") templates = Jinja2Templates(directory="src/templates")
logger = logging.getLogger(__name__)
def _client(log_cb=None) -> TinkClient: 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 # Server-side token store — keeps JWTs OUT of the session cookie
# (cookie limit is 4KB; two JWTs alone are ~1.3KB before base64 overhead) # (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} _token_store: dict[str, dict] = {} # sid → {"app_token": str, "user_token": str}
_callback_locks: dict[str, asyncio.Lock] = {} # sid → Lock (prevents concurrent code exchange) _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") @router.get("/demo/debug-session")
async def debug_session(request: Request): 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) sess = _session(request)
safe = { safe = {
k: (v[:20] + "" if isinstance(v, str) and len(v) > 20 else v) 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) @router.get("/demo/step/1", response_class=HTMLResponse)
async def step1(request: Request): 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)) client = _client(log_cb=_logger(sess))
s = get_settings() s = get_settings()
error = None error = None
@@ -169,6 +187,10 @@ async def step1(request: Request):
@router.get("/demo/step/2", response_class=HTMLResponse) @router.get("/demo/step/2", response_class=HTMLResponse)
async def step2_get(request: Request): 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) sess = _session(request)
s = get_settings() s = get_settings()
app_token = _load_token(sess, "app_token") app_token = _load_token(sess, "app_token")
@@ -196,6 +218,12 @@ async def step2_get(request: Request):
async def step2_post(request: Request, async def step2_post(request: Request,
customer_name: str = Form(default=""), customer_name: str = Form(default=""),
market: str = Form(default="DK")): 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) sess = _session(request)
s = get_settings() s = get_settings()
app_token = _load_token(sess, "app_token") 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) @router.get("/demo/step/3", response_class=HTMLResponse)
async def step3_get(request: Request): 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) sess = _session(request)
s = get_settings() s = get_settings()
client = _client(log_cb=_logger(sess)) 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): credentials_id: Optional[str] = None):
"""Tink Link OAuth callback — exchange code for user token.""" """Tink Link OAuth callback — exchange code for user token."""
sess = _session(request) 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: if error:
print(f"[CALLBACK] Tink returned error: {error}") logger.warning("Tink returned error: %s", error)
return RedirectResponse(f"/demo/step/3?error={error}") return RedirectResponse(f"/demo/step/3?error={error}")
if code: if code:
sid = sess.get("sid", "unknown") sid = sess.get("sid", "unknown")
@@ -418,31 +453,29 @@ async def tink_callback(request: Request, code: Optional[str] = None,
_callback_locks[sid] = asyncio.Lock() _callback_locks[sid] = asyncio.Lock()
async with _callback_locks[sid]: async with _callback_locks[sid]:
if _load_token(sess, "user_token"): 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) return RedirectResponse("/demo/step/3?cb_success=1", status_code=303)
try: try:
s = get_settings() 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)) client = _client(log_cb=_logger(sess))
tokens = await client.exchange_code_for_token( tokens = await client.exchange_code_for_token(
code, redirect_uri=s.tink_redirect_uri code, redirect_uri=s.tink_redirect_uri
) )
print(f"[CALLBACK] Token response keys: {list(tokens.keys())}")
user_token = tokens.get("access_token", "") user_token = tokens.get("access_token", "")
if not user_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( return RedirectResponse(
f"/demo/step/3?cb_error=Token+exchange+ok+but+access_token+missing+in+response", f"/demo/step/3?cb_error=Token+exchange+ok+but+access_token+missing+in+response",
status_code=303, status_code=303,
) )
_store_token(sess, "user_token", user_token) _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) return RedirectResponse("/demo/step/3?cb_success=1", status_code=303)
except Exception as e: except Exception as e:
import traceback logger.exception("Token exchange failed: %s", e)
print(f"[CALLBACK] EXCEPTION: {e}\n{traceback.format_exc()}")
return RedirectResponse(f"/demo/step/3?cb_error={str(e)}", status_code=303) 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) 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) @router.get("/demo/step/4", response_class=HTMLResponse)
async def step4(request: Request): 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) sess = _session(request)
s = get_settings() s = get_settings()
user_token = _load_token(sess, "user_token") user_token = _load_token(sess, "user_token")
@@ -501,6 +539,11 @@ async def step4(request: Request):
@router.get("/demo/step/5", response_class=HTMLResponse) @router.get("/demo/step/5", response_class=HTMLResponse)
async def step5(request: Request, account_id: Optional[str] = None): 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) sess = _session(request)
s = get_settings() s = get_settings()
user_token = _load_token(sess, "user_token") 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) @router.get("/demo/step/6", response_class=HTMLResponse)
async def step6(request: Request): 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) sess = _session(request)
s = get_settings() s = get_settings()
is_demo = sess.get("demo_mode") or s.demo_mode is_demo = sess.get("demo_mode") or s.demo_mode
@@ -657,8 +706,20 @@ async def clear_log(request: Request):
@router.post("/webhooks/tink") @router.post("/webhooks/tink")
async def webhook_receiver(request: Request): 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() body = await request.json()
# In production you'd verify the signature and store events logger.info("Webhook received: %s", json.dumps(body)[:200])
print(f"[WEBHOOK] {json.dumps(body, indent=2)}")
return {"status": "received"} 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 class="w-8 h-8 rounded-lg bg-violet-600 flex items-center justify-center text-white font-bold text-sm">T</div>
<div> <div>
<span class="font-semibold text-white">Tink API Demo</span> <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> </div>
</a> </a>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@@ -71,7 +71,7 @@
<!-- Footer --> <!-- Footer -->
<footer class="border-t border-slate-800 text-center text-slate-500 text-xs py-4"> <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> </footer>
<script> <script>

View File

@@ -117,6 +117,19 @@
{% else %} {% else %}
<!-- Not yet connected — show connection UI --> <!-- 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 --> <!-- 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">

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> <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> </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">
Opret en Tink-bruger med en <code class="text-violet-300">tink_external_ref</code> Opret en Tink-bruger med et <code class="text-violet-300">external_user_id</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>). jeres interne reference til kunden (f.eks. et kundenummer eller UUID fra jeres database).
Tink returnerer et <code class="text-violet-300">user_id</code> som bruges i efterfølgende kald. 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> </p>
</div> </div>
@@ -89,8 +91,8 @@
<div class="px-5 py-4 border-b border-slate-800"> <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-sm font-semibold text-white">Hvem opretter vi?</p>
<p class="text-xs text-slate-400 mt-0.5"> <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 <code class="text-violet-300">external_user_id</code> = jeres interne kundereference
adskilt fra jeres interne <code class="text-slate-400">customer_id</code> gemmes i jeres database som f.eks. <code class="text-slate-400">tink_user_ref</code>
</p> </p>
</div> </div>
<form method="POST" action="/demo/step/2" class="p-5 space-y-4"> <form method="POST" action="/demo/step/2" class="p-5 space-y-4">
@@ -101,7 +103,7 @@
value="Henrik Jess" 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 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"> 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>
<div> <div>
<label class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Marked</label> <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> <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> <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"> <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: <pre class="text-xs text-amber-300 font-mono whitespace-pre"># Your DB schema (example):
# customer_id = 42 ← jeres interne ID (Tink ser det aldrig) # customer_id = 42 your internal ID (Tink never sees this)
# tink_external_ref = "moneycapp-42-a3f9c1" ← Tink-reference # tink_user_ref = "moneycapp-42-a3f9c1" ← store Tink's external_user_id here
# Request body # 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", "market": "DK",
"locale": "da_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. Fra brugeroprettelse over live bankdata til real-time webhooks — alt via Tink API.
</p> </p>
<p class="text-slate-500 text-sm mb-6 max-w-xl mx-auto"> <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. Auth → bruger → bank-tilslutning → konti (v2) → transaktioner (v2) → webhooks.
Det er præcis hvad MoneyCapp mangler for at gøre deres integration robust.
</p> </p>
<div class="flex gap-3 justify-center flex-wrap"> <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="/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>
</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 --> <!-- Navigation -->
<div class="mt-4 flex justify-start"> <div class="mt-4 flex justify-start">
<a href="/demo/step/5" <a href="/demo/step/5"

View File

@@ -1,6 +1,9 @@
""" """
Tink API client — async httpx wrapper covering auth, users, Tink API client — async httpx wrapper covering auth, users,
accounts (v2), transactions (v2), events (v2), and bank connectivity. 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 import json
@@ -89,7 +92,10 @@ class TinkClient:
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
async def get_app_token(self, scope: str = "user:create") -> dict: 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( return await self._post(
f"{self.api_base}/api/v1/oauth/token", f"{self.api_base}/api/v1/oauth/token",
headers={"Content-Type": "application/x-www-form-urlencoded"}, headers={"Content-Type": "application/x-www-form-urlencoded"},
@@ -103,6 +109,10 @@ class TinkClient:
async def exchange_code_for_token(self, code: str, async def exchange_code_for_token(self, code: str,
redirect_uri: str | None = None) -> dict: 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 = { data: dict = {
"client_id": self.client_id, "client_id": self.client_id,
"client_secret": self.client_secret, "client_secret": self.client_secret,
@@ -123,6 +133,11 @@ class TinkClient:
async def create_user(self, app_token: str, external_user_id: str, async def create_user(self, app_token: str, external_user_id: str,
market: str = "DK", locale: str = "da_DK") -> dict: 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( return await self._post(
f"{self.api_base}/api/v1/user/create", f"{self.api_base}/api/v1/user/create",
headers={"Authorization": f"Bearer {app_token}"}, 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, async def get_authorization_grant_token(self, app_token: str, user_id: str,
scope: str) -> dict: 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( return await self._post(
f"{self.api_base}/api/v1/oauth/authorization-grant/delegate", f"{self.api_base}/api/v1/oauth/authorization-grant/delegate",
headers={ headers={
@@ -153,6 +173,12 @@ class TinkClient:
def get_tink_link_url(self, market: str = "DK", def get_tink_link_url(self, market: str = "DK",
authorization_code: str | None = None, authorization_code: str | None = None,
redirect_uri_override: str | None = None) -> str: 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 from urllib.parse import urlencode
params: dict = { params: dict = {
"client_id": self.client_id, "client_id": self.client_id,
@@ -184,6 +210,10 @@ class TinkClient:
async def list_accounts(self, user_token: str, page_size: int = 50, async def list_accounts(self, user_token: str, page_size: int = 50,
page_token: Optional[str] = None) -> dict: 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} params: dict = {"pageSize": page_size}
if page_token: if page_token:
params["pageToken"] = page_token params["pageToken"] = page_token
@@ -206,6 +236,10 @@ class TinkClient:
async def list_transactions(self, user_token: str, page_size: int = 25, async def list_transactions(self, user_token: str, page_size: int = 25,
page_token: Optional[str] = None, page_token: Optional[str] = None,
account_id: Optional[str] = None) -> dict: 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} params: dict = {"pageSize": page_size}
if page_token: if page_token:
params["pageToken"] = page_token params["pageToken"] = page_token
@@ -265,6 +299,10 @@ class TinkClient:
async def register_webhook(self, app_token: str, url: str, async def register_webhook(self, app_token: str, url: str,
enabled_events: list[str] | None = None) -> dict: 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( return await self._post(
f"{self.api_base}/api/v1/webhooks", f"{self.api_base}/api/v1/webhooks",
headers={"Authorization": f"Bearer {app_token}"}, headers={"Authorization": f"Bearer {app_token}"},