fix: customer-facing cleanup — remove internal branding, print→logging, dynamic dates, sandbox note, next steps
All checks were successful
Build and Deploy / deploy (push) Successful in 49s

- main.py: neutral API description (remove 'sales demo')
- base.html: 'Open Banking Demo' nav, neutral footer
- demo.py: /debug-session gated behind DEMO_MODE, all print() → logging,
  webhook receiver has C# signature verification example + Tink docs link,
  removed duplicate import
- demo_data.py: all hardcoded 2026 dates replaced with dynamic date helpers
- step2.html: external_user_id terminology (remove tink_external_ref)
- step.html: sandbox note on Step 3 (anonymous vs production flow)
- step6.html: 'Next Steps' section for C#/.NET implementation
This commit is contained in:
Henrik Jess Nielsen
2026-05-23 01:50:20 +02:00
parent 3851b6c478
commit f6d17ae3c5
7 changed files with 115 additions and 53 deletions

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)
@@ -430,9 +436,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")
@@ -440,31 +446,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)
@@ -695,8 +699,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; i80.dk Tink Open Banking API Demo
</footer> </footer>
<script> <script>

View File

@@ -114,9 +114,21 @@
</div> </div>
</div> </div>
{% 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">
Auth → bruger → bank-tilslutning → konti (v2) → transaktioner (v2) → webhooks. Auth → bruger → bank-tilslutning → konti (v2) → transaktioner (v2) → webhooks.
Et komplet Tink-flow — klar til at blive bygget ind i jeres platform.
</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"