Files
tink-demo/src/routes/demo.py

703 lines
28 KiB
Python
Raw Normal View History

2026-05-22 18:30:59 +02:00
"""
Demo routes each /demo/step/N page shows one Tink API call
with the live JSON response and a curl example.
"""
import json
import uuid
import secrets
from typing import Optional
from fastapi import APIRouter, Request, Form, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from src.tink.client import TinkClient
from src.config import get_settings
from src import demo_data
router = APIRouter()
templates = Jinja2Templates(directory="src/templates")
2026-05-22 19:18:38 +02:00
def _client(log_cb=None) -> TinkClient:
2026-05-22 18:30:59 +02:00
s = get_settings()
return TinkClient(
client_id=s.tink_client_id,
client_secret=s.tink_client_secret,
redirect_uri=s.tink_redirect_uri,
api_base=s.tink_api_base,
link_base=s.tink_link_base,
2026-05-22 19:18:38 +02:00
on_request=log_cb,
2026-05-22 18:30:59 +02:00
)
def _session(request: Request) -> dict:
return request.session.setdefault("demo", {})
# ---------------------------------------------------------------------------
# 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)
def _get_sid(sess: dict) -> str:
"""Return (creating if needed) a stable session ID stored in the cookie."""
if "sid" not in sess:
sess["sid"] = str(uuid.uuid4())
return sess["sid"]
def _store_token(sess: dict, key: str, value: str) -> None:
"""Save a JWT in the server-side store instead of the cookie."""
sid = _get_sid(sess)
_token_store.setdefault(sid, {})[key] = value
def _load_token(sess: dict, key: str, default: str = "") -> str:
"""Read a JWT from the server-side store."""
sid = sess.get("sid", "")
return _token_store.get(sid, {}).get(key, default)
2026-05-22 19:18:38 +02:00
def _ctx(request: Request, extra: dict) -> dict:
"""Base template context — always includes session_customer."""
sess = _session(request)
return {"request": request, "session_customer": sess.get("external_user_id", ""), **extra}
def _logger(sess: dict):
"""Returns a callback that appends log entries to server-side store (not cookie)."""
2026-05-22 19:18:38 +02:00
def cb(entry: dict):
sid = sess.get("sid", "")
if not sid:
return
store = _token_store.setdefault(sid, {})
log = store.setdefault("api_log", [])
2026-05-22 19:18:38 +02:00
log.append(entry)
if len(log) > 50:
store["api_log"] = log[-50:]
2026-05-22 19:18:38 +02:00
return cb
def _get_api_log(sess: dict) -> list:
sid = sess.get("sid", "")
return _token_store.get(sid, {}).get("api_log", [])
2026-05-22 18:30:59 +02:00
# ---------------------------------------------------------------------------
# Landing
# ---------------------------------------------------------------------------
@router.get("/", response_class=HTMLResponse)
async def index(request: Request):
return templates.TemplateResponse("index.html", {"request": request})
@router.get("/demo/reset")
async def reset_demo(request: Request):
"""Clear all demo session state and restart from Step 1."""
sess = _session(request)
sid = sess.get("sid", "")
if sid:
_token_store.pop(sid, None)
2026-05-22 18:30:59 +02:00
request.session.pop("demo", None)
return RedirectResponse("/demo/step/1")
@router.get("/demo/debug-session")
async def debug_session(request: Request):
"""Show current session keys (debug only)."""
sess = _session(request)
safe = {
k: (v[:20] + "" if isinstance(v, str) and len(v) > 20 else v)
for k, v in sess.items()
if k != "api_log"
}
safe["api_log_count"] = len(_get_api_log(sess))
safe["cookie_size_bytes"] = len(str(request.session))
return safe
2026-05-22 18:30:59 +02:00
# ---------------------------------------------------------------------------
# Step 1 — Authenticate (client credentials)
# ---------------------------------------------------------------------------
@router.get("/demo/step/1", response_class=HTMLResponse)
async def step1(request: 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
"""
2026-05-22 19:18:38 +02:00
sess = _session(request)
client = _client(log_cb=_logger(sess))
2026-05-22 18:30:59 +02:00
s = get_settings()
error = None
result = None
curl_example = (
f"curl -X POST https://api.tink.com/api/v1/oauth/token \\\n"
f" -d 'client_id={s.tink_client_id}' \\\n"
f" -d 'client_secret=***' \\\n"
f" -d 'grant_type=client_credentials' \\\n"
f" -d 'scope=user:create,authorization:grant'"
2026-05-22 18:30:59 +02:00
)
try:
result = await client.get_app_token(scope="user:create,authorization:grant")
_store_token(sess, "app_token", result["access_token"])
2026-05-22 18:30:59 +02:00
except Exception as e:
error = str(e)
2026-05-22 19:18:38 +02:00
return templates.TemplateResponse("step.html", _ctx(request, {
2026-05-22 18:30:59 +02:00
"step": 1,
"title": "Authenticate",
"subtitle": "Client Credentials Flow",
"endpoint": "POST /api/v1/oauth/token",
"api_version": "v1",
"description": "Vi starter med at hente et app-level access token via Client Credentials flow. Dette token bruges til at oprette brugere.",
"curl_example": curl_example,
"result": result,
"error": error,
"next_step": 2,
"prev_step": None,
2026-05-22 19:18:38 +02:00
}))
2026-05-22 18:30:59 +02:00
# ---------------------------------------------------------------------------
# Step 2 — Create User
# ---------------------------------------------------------------------------
@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.
"""
2026-05-22 18:30:59 +02:00
sess = _session(request)
s = get_settings()
app_token = _load_token(sess, "app_token")
2026-05-22 19:18:38 +02:00
error = None
2026-05-22 18:30:59 +02:00
2026-05-22 19:18:38 +02:00
if not app_token:
error = "Mangler app token — gå tilbage til Step 1 og kør authentication først."
# If user already created this session, skip the form
existing_user_id = sess.get("user_id", "")
existing_external_id = sess.get("external_user_id", "")
return templates.TemplateResponse("step2.html", _ctx(request, {
"step": 2,
"error": error,
"existing_user_id": existing_user_id,
"existing_external_id": existing_external_id,
"app_token_ok": bool(app_token),
"next_step": 3,
"prev_step": 1,
}))
@router.post("/demo/step/2", response_class=HTMLResponse)
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
"""
2026-05-22 19:18:38 +02:00
sess = _session(request)
s = get_settings()
app_token = _load_token(sess, "app_token")
2026-05-22 19:18:38 +02:00
error = None
result = None
# Build external_user_id — always unique per run (simulates real customer UUID)
short_id = secrets.token_hex(3) # 6-char hex, e.g. "a3f9c1"
2026-05-22 19:18:38 +02:00
if customer_name.strip():
import re
slug = customer_name.strip().lower().replace(" ", "-")
2026-05-22 19:18:38 +02:00
slug = re.sub(r"[^a-z0-9\-]", "", slug)
external_user_id = f"moneycapp-{slug}-{short_id}"
2026-05-22 19:18:38 +02:00
else:
external_user_id = f"moneycapp-{short_id}"
2026-05-22 19:18:38 +02:00
body = json.dumps({"external_user_id": external_user_id, "market": market, "locale": "da_DK"}, indent=2)
2026-05-22 18:30:59 +02:00
curl_example = (
f"curl -X POST https://api.tink.com/api/v1/user/create \\\n"
f" -H 'Authorization: Bearer $APP_TOKEN' \\\n"
f" -H 'Content-Type: application/json' \\\n"
f" -d '{body}'"
)
if not app_token:
2026-05-22 19:18:38 +02:00
error = "Mangler app token — gå tilbage til Step 1."
2026-05-22 18:30:59 +02:00
else:
try:
2026-05-22 19:18:38 +02:00
client = _client(log_cb=_logger(sess))
result = await client.create_user(app_token, external_user_id, market=market)
2026-05-22 18:30:59 +02:00
sess["user_id"] = result.get("user_id", "")
sess["external_user_id"] = external_user_id
sess["user_market"] = market
# New user — clear any stale tokens from a previous user
sess.pop("user_token", None)
sess.pop("demo_mode", None)
2026-05-22 18:30:59 +02:00
except Exception as e:
error = str(e)
2026-05-22 19:18:38 +02:00
return templates.TemplateResponse("step.html", _ctx(request, {
2026-05-22 18:30:59 +02:00
"step": 2,
"title": "Opret Bruger",
2026-05-22 19:18:38 +02:00
"subtitle": f"Kunde: {external_user_id}",
2026-05-22 18:30:59 +02:00
"endpoint": "POST /api/v1/user/create",
"api_version": "v1",
"description": f"Oprettet Tink-bruger med <code class='text-violet-300 font-mono'>tink_external_ref</code> = <code class='text-violet-300 font-mono'>{external_user_id}</code> — MoneyCapp's reference til kunden i Tink. Tink returnerer et <code class='text-violet-300 font-mono'>user_id</code> som bruges i næste kald.",
2026-05-22 18:30:59 +02:00
"curl_example": curl_example,
"result": result,
"error": error,
"next_step": 3,
"prev_step": 1,
2026-05-22 19:18:38 +02:00
}))
2026-05-22 18:30:59 +02:00
# ---------------------------------------------------------------------------
# Step 3 — Connect Bank (Tink Link redirect)
# ---------------------------------------------------------------------------
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
"""
2026-05-22 18:30:59 +02:00
sess = _session(request)
s = get_settings()
client = _client(log_cb=_logger(sess))
2026-05-22 18:30:59 +02:00
error = None
credentials = None
cb_success = request.query_params.get("cb_success")
cb_error = request.query_params.get("cb_error")
user_id = sess.get("user_id", "")
app_token = _load_token(sess, "app_token")
grant_result = None
# Call authorization-grant/delegate to demonstrate the API and get a code.
# We show this in the response viewer so the audience sees it works.
# The actual Tink Link flow uses anonymous mode (sandbox limitation),
# but in production you'd pass authorization_code in the URL.
if user_id and app_token and not _load_token(sess, "user_token"):
try:
grant_result = await client.get_authorization_grant_token(
app_token=app_token,
user_id=user_id,
scope="accounts:read,transactions:read,credentials:read",
)
except Exception as e:
error = str(e)
# Always use anonymous flow for the actual Tink Link URL — works in sandbox.
# Production apps would add authorization_code=<code> instead of scope.
tink_link_url = client.get_tink_link_url(market=sess.get("user_market", "DK"))
dev_tink_link_url = client.get_tink_link_url(
market=sess.get("user_market", "DK"),
redirect_uri_override=CONSOLE_CALLBACK,
)
2026-05-22 18:30:59 +02:00
# Demo mode: auto-mark as connected with mock data
if s.demo_mode and not _load_token(sess, "user_token"):
_store_token(sess, "user_token", "demo-mode-token")
2026-05-22 18:30:59 +02:00
sess["demo_mode"] = True
# Check if already connected (returning from callback)
user_token = _load_token(sess, "user_token")
2026-05-22 18:30:59 +02:00
if user_token:
if sess.get("demo_mode"):
credentials = demo_data.MOCK_CREDENTIALS
cb_success = cb_success or "demo"
else:
try:
credentials = await client.list_credentials(user_token)
except Exception as e:
2026-05-22 19:18:38 +02:00
if "403" in str(e):
credentials = {"note": "credentials:read scope ikke tildelt — brug accounts:read i stedet"}
2026-05-22 19:18:38 +02:00
else:
error = str(e)
2026-05-22 18:30:59 +02:00
external_user_id = sess.get("external_user_id", "")
uid_display = user_id or "$USER_ID"
grant_code = (grant_result or {}).get("code", "$AUTHORIZATION_CODE")
2026-05-22 18:30:59 +02:00
curl_example = (
"# Step 1: Generer authorization_code for din specifikke bruger\n"
"curl -X POST https://api.tink.com/api/v1/oauth/authorization-grant/delegate \\\n"
" -H 'Authorization: Bearer $APP_TOKEN' \\\n"
" -d 'actor_client_id=$CLIENT_ID' \\\n"
f" -d 'user_id={uid_display}' \\\n"
" -d 'scope=accounts:read,transactions:read'\n"
"# → { \"code\": \"AUTHORIZATION_CODE\" }\n\n"
"# Step 2: Byg Tink Link URL med authorization_code (binder bank til din bruger)\n"
"https://link.tink.com/1.0/transactions/connect-accounts\n"
" ?client_id=$CLIENT_ID\n"
" &redirect_uri=$REDIRECT_URI\n"
f" &authorization_code={grant_code}\n\n"
"# Step 3: Callback → exchange code for user token\n"
2026-05-22 18:30:59 +02:00
"curl -X POST https://api.tink.com/api/v1/oauth/token \\\n"
" -d 'grant_type=authorization_code' \\\n"
" -d 'client_id=$CLIENT_ID' \\\n"
" -d 'client_secret=$CLIENT_SECRET' \\\n"
" -d 'code=$CALLBACK_CODE'"
2026-05-22 18:30:59 +02:00
)
if grant_result:
description = (
f"authorization-grant/delegate kaldt for bruger <code class='text-violet-300 font-mono'>{external_user_id}</code> — "
f"returnerede <code class='text-violet-300 font-mono'>code</code> som vist nedenfor. "
f"I produktion sendes denne code med i Tink Link URL som <code class='text-violet-300 font-mono'>authorization_code</code>."
)
elif user_id:
description = (
"Åbn Tink Link → vælg <b>Tink Demo Bank</b> → log ind med testbruger. "
"Klik <b>Vis testbrugere</b> for at se login til DK, SE, NO, FI, DE m.fl."
)
else:
description = "Gå til Step 2 for at oprette en bruger, klik derefter her for at tilslutte banken."
# Tink Demo Bank test users per market
# Source: https://docs.tink.com/resources/tutorials/test-your-integration-with-demo-bank
demo_bank_users = [
{"market": "🇩🇰 DK", "username": "u04877810", "password": "vxw774", "otp": "1234", "scenario": "Succes"},
{"market": "🇩🇰 DK", "username": "u92721594", "password": "nbs589", "otp": "", "scenario": "Auth-fejl"},
{"market": "🇸🇪 SE", "username": "u59803783", "password": "hwj858", "otp": "1234", "scenario": "Succes"},
{"market": "🇸🇪 SE", "username": "u91817276", "password": "cft248", "otp": "", "scenario": "Auth-fejl"},
{"market": "🇳🇴 NO", "username": "u24765398", "password": "xjf459", "otp": "1234", "scenario": "Succes"},
{"market": "🇫🇮 FI", "username": "u19283746", "password": "zkm291", "otp": "1234", "scenario": "Succes"},
{"market": "🇩🇪 DE", "username": "u38471920", "password": "bvp103", "otp": "1234", "scenario": "Succes"},
{"market": "🇬🇧 GB", "username": "u72910483", "password": "qrt567", "otp": "1234", "scenario": "Succes"},
{"market": "🇳🇱 NL", "username": "u56473829", "password": "lmn482", "otp": "1234", "scenario": "Succes"},
{"market": "🇫🇷 FR", "username": "u84920173", "password": "pqs736", "otp": "1234", "scenario": "Succes"},
]
2026-05-22 19:18:38 +02:00
return templates.TemplateResponse("step.html", _ctx(request, {
2026-05-22 18:30:59 +02:00
"step": 3,
"title": "Tilslut Bank",
"subtitle": "Tink Link — Bank Connection",
"endpoint": "POST /api/v1/oauth/authorization-grant/delegate",
"api_version": "v1",
"description": description,
2026-05-22 18:30:59 +02:00
"curl_example": curl_example,
"result": grant_result or credentials,
2026-05-22 18:30:59 +02:00
"tink_link_url": tink_link_url,
"dev_tink_link_url": dev_tink_link_url,
"demo_bank_users": demo_bank_users,
2026-05-22 18:30:59 +02:00
"error": error or (f"Callback fejl: {cb_error}" if cb_error else None),
"cb_success": cb_success,
"next_step": 4,
"prev_step": 2,
2026-05-22 19:18:38 +02:00
}))
2026-05-22 18:30:59 +02:00
@router.post("/demo/step/3", response_class=HTMLResponse)
async def step3_post(request: Request, code: str = Form(...)):
"""Manual code entry — exchange a code obtained via console.tink.com/callback."""
sess = _session(request)
try:
2026-05-22 19:18:38 +02:00
client = _client(log_cb=_logger(sess))
2026-05-22 18:30:59 +02:00
tokens = await client.exchange_code_for_token(
code=code.strip(),
redirect_uri=CONSOLE_CALLBACK,
)
_store_token(sess, "user_token", tokens.get("access_token", ""))
2026-05-22 18:30:59 +02:00
return RedirectResponse("/demo/step/3?cb_success=1", status_code=303)
except Exception as e:
return RedirectResponse(f"/demo/step/3?cb_error={str(e)}", status_code=303)
@router.get("/callback", response_class=HTMLResponse)
async def tink_callback(request: Request, code: Optional[str] = None,
error: 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())}")
2026-05-22 18:30:59 +02:00
if error:
print(f"[CALLBACK] Tink returned error: {error}")
2026-05-22 18:30:59 +02:00
return RedirectResponse(f"/demo/step/3?error={error}")
if code:
sid = sess.get("sid", "unknown")
if sid not in _callback_locks:
_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")
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}")
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}")
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)")
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()}")
return RedirectResponse(f"/demo/step/3?cb_error={str(e)}", status_code=303)
print(f"[CALLBACK] No code — bare redirect to step 3")
return RedirectResponse("/demo/step/3", status_code=303)
2026-05-22 18:30:59 +02:00
# ---------------------------------------------------------------------------
# Step 4 — Accounts (v2)
# ---------------------------------------------------------------------------
@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
"""
2026-05-22 18:30:59 +02:00
sess = _session(request)
s = get_settings()
user_token = _load_token(sess, "user_token")
2026-05-22 18:30:59 +02:00
error = None
result = None
is_demo = sess.get("demo_mode") or s.demo_mode
curl_example = (
"curl https://api.tink.com/data/v2/accounts \\\n"
" -H 'Authorization: Bearer $USER_TOKEN'"
)
if not user_token and not is_demo:
error = "Mangler user token — tilslut en bank i Step 3 først."
elif is_demo:
result = demo_data.MOCK_ACCOUNTS
else:
try:
client = _client(log_cb=_logger(sess))
2026-05-22 18:30:59 +02:00
result = await client.list_accounts(user_token)
# Fall back to demo data if no accounts connected yet
if not result.get("accounts"):
result = demo_data.MOCK_ACCOUNTS
sess["demo_mode"] = True
except Exception as e:
error = str(e)
return templates.TemplateResponse("step.html", _ctx(request, {
2026-05-22 18:30:59 +02:00
"step": 4,
"title": "Konti",
"subtitle": "Account List with Balances",
"endpoint": "GET /data/v2/accounts",
"api_version": "v2 ✦",
"description": "Henter brugerens konti via det nye v2 data endpoint. Returnerer account type, balance, currency og IBAN.",
"curl_example": curl_example,
"result": result,
"error": error,
"is_demo": is_demo or (result == demo_data.MOCK_ACCOUNTS),
"next_step": 5,
"prev_step": 3,
}))
2026-05-22 18:30:59 +02:00
# ---------------------------------------------------------------------------
# Step 5 — Transactions (v2)
# ---------------------------------------------------------------------------
@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
"""
2026-05-22 18:30:59 +02:00
sess = _session(request)
s = get_settings()
user_token = _load_token(sess, "user_token")
2026-05-22 18:30:59 +02:00
is_demo = sess.get("demo_mode") or s.demo_mode
error = None
result = None
curl_example = (
"curl 'https://api.tink.com/data/v2/transactions?pageSize=25' \\\n"
" -H 'Authorization: Bearer $USER_TOKEN'"
)
if not user_token and not is_demo:
error = "Mangler user token — tilslut en bank i Step 3 først."
elif is_demo:
result = demo_data.MOCK_TRANSACTIONS
else:
try:
client = _client(log_cb=_logger(sess))
2026-05-22 18:30:59 +02:00
result = await client.list_transactions(user_token, account_id=account_id)
if not result.get("transactions"):
result = demo_data.MOCK_TRANSACTIONS
sess["demo_mode"] = True
except Exception as e:
error = str(e)
return templates.TemplateResponse("step.html", _ctx(request, {
2026-05-22 18:30:59 +02:00
"step": 5,
"title": "Transaktioner",
"subtitle": "Transaction History",
"endpoint": "GET /data/v2/transactions",
"api_version": "v2 ✦",
"description": "Henter transaktioner via v2 endpoint med paginering. Returnerer amount, description, status (BOOKED/PENDING), og kategorisering.",
"curl_example": curl_example,
"result": result,
"error": error,
"is_demo": is_demo or (result == demo_data.MOCK_TRANSACTIONS),
"next_step": 6,
"prev_step": 4,
}))
2026-05-22 18:30:59 +02:00
# ---------------------------------------------------------------------------
# Step 6 — Events (v2)
# ---------------------------------------------------------------------------
@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
"""
2026-05-22 18:30:59 +02:00
sess = _session(request)
s = get_settings()
is_demo = sess.get("demo_mode") or s.demo_mode
error = None
result_webhooks = None
webhook_registered = None
2026-05-22 18:30:59 +02:00
webhook_url = f"{s.app_base_url}/webhooks/tink"
curl_list = (
"# List registered webhooks (app-level token)\n"
"curl 'https://api.tink.com/api/v1/webhooks' \\\n"
" -H 'Authorization: Bearer $APP_TOKEN'"
2026-05-22 18:30:59 +02:00
)
curl_register = (
"# Register webhook endpoint\n"
"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"
" }'"
2026-05-22 18:30:59 +02:00
)
if is_demo:
result_webhooks = demo_data.MOCK_EVENTS_BOOKED
webhook_registered = {
"id": "wh-demo-001",
"url": webhook_url,
"enabledEvents": ["account-booked-transaction:created", "account-pending-transaction:created"],
"status": "ENABLED",
}
2026-05-22 18:30:59 +02:00
else:
try:
client = _client(log_cb=_logger(sess))
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]
2026-05-22 18:30:59 +02:00
except Exception as e:
err_str = str(e)
if "404" in err_str:
result_webhooks = {"note": "Webhook API ikke tilgængeligt i sandbox — kun i produktion"}
webhook_registered = {
"url": webhook_url,
"enabledEvents": ["account-booked-transaction:created", "account-pending-transaction:created"],
"status": "ENABLED (eksempel)",
}
else:
error = err_str
2026-05-22 18:30:59 +02:00
return templates.TemplateResponse("step6.html", _ctx(request, {
2026-05-22 18:30:59 +02:00
"step": 6,
"title": "Webhooks & Events",
"subtitle": "Real-time Event Notifications",
2026-05-22 18:30:59 +02:00
"error": error,
"result_webhooks": result_webhooks,
"webhook_registered": webhook_registered,
"webhook_url": webhook_url,
"curl_list": curl_list,
"curl_register": curl_register,
2026-05-22 18:30:59 +02:00
"is_demo": is_demo,
"app_base_url": s.app_base_url,
"next_step": None,
"prev_step": 5,
}))
# ---------------------------------------------------------------------------
# API Request Log
# ---------------------------------------------------------------------------
@router.get("/demo/log", response_class=HTMLResponse)
async def api_log(request: Request):
sess = _session(request)
log = _get_api_log(sess)
return templates.TemplateResponse("log.html", _ctx(request, {
"log": list(reversed(log)), # newest first
"log_count": len(log),
}))
@router.post("/demo/log/clear")
async def clear_log(request: Request):
sess = _session(request)
sid = sess.get("sid", "")
if sid and sid in _token_store:
_token_store[sid].pop("api_log", None)
return RedirectResponse("/demo/log", status_code=303)
2026-05-22 18:30:59 +02:00
# ---------------------------------------------------------------------------
# Webhook receiver
# ---------------------------------------------------------------------------
@router.post("/webhooks/tink")
async def webhook_receiver(request: Request):
"""Receive Tink webhook events (configure URL in Tink Console)."""
body = await request.json()
# In production you'd verify the signature and store events
print(f"[WEBHOOK] {json.dumps(body, indent=2)}")
return {"status": "received"}