Integration Specifications

Third-party integration architecture and flows

BankID OIDC Integration

BankID OIDC Integration

Version: 1.0 Date: 2026-02-21 Author: Banking Architecture Team Status: Approved Applies to: Drop — Authentication via Norwegian BankID


1. Overview

Drop uses BankID OIDC (OpenID Connect) as its sole authentication method. BankID provides Strong Customer Authentication (SCA) out of the box — combining possession (mobile device / code generator) with knowledge (personal code) or inherence (biometrics).

Email/password login has been removed. All deprecated auth endpoints return 410 Gone.

Property Value
Identity Provider BankID (prod: auth.bankid.no)
Protocol OpenID Connect 1.0 (Authorization Code Flow)
Auth endpoint https://auth.bankid.no/auth/realms/prod/protocol/openid-connect/auth
Token endpoint https://auth.bankid.no/auth/realms/prod/protocol/openid-connect/token
JWKS endpoint https://auth.bankid.no/auth/realms/prod/protocol/openid-connect/certs
Issuer https://auth.bankid.no/auth/realms/prod
Scopes openid profile
Response type code (authorization code flow)
Source code src/drop-api/src/lib/bankid.ts

2. BankID OIDC Flow

2.1 Web Flow (Next.js BFF)

sequenceDiagram
    participant B as Browser
    participant N as Next.js BFF<br/>/api/auth/bankid/*
    participant BID as BankID OIDC<br/>(auth.bankid.no)

    B->>N: GET /api/auth/bankid
    Note over N: Rate limit check (10/min per IP)
    N->>N: Generate state (crypto.randomUUID)<br/>Generate nonce (crypto.randomUUID)
    N->>N: Set httpOnly cookie: bankid_state={state}
    N-->>B: {redirectUrl: "https://auth.bankid.no/auth/...?<br/>client_id=X&redirect_uri=Y&response_type=code<br/>&scope=openid+profile&state=Z&nonce=N"}

    B->>BID: Browser redirects to BankID authorize URL
    Note over B,BID: User authenticates with BankID<br/>(BankID app / code generator / biometrics)
    BID-->>B: 302 redirect to /api/auth/bankid/callback<br/>?code=AUTH_CODE&state=Z

    B->>N: GET /api/auth/bankid/callback?code=AUTH_CODE&state=Z
    N->>N: Verify state matches bankid_state cookie
    N->>BID: POST /token<br/>{grant_type: authorization_code,<br/>code: AUTH_CODE, redirect_uri: Y,<br/>client_id: X, client_secret: S}
    BID-->>N: {id_token: "eyJ...", access_token: "...",<br/>token_type: "Bearer", expires_in: 300}

    N->>N: Verify id_token signature via JWKS
    N->>N: Validate: issuer, audience, expiry, nonce
    N->>N: Extract pid (fødselsnummer) from claims
    N->>N: Parse birthdate from pid, verify age >= 18
    N->>N: findOrCreateUser(pid, name)
    N->>N: Create session (sessions table)
    N->>N: Issue Drop JWT, set httpOnly cookie (drop_token)
    N-->>B: 302 redirect to /dashboard

2.2 Mobile Flow (Hono API + Expo)

sequenceDiagram
    participant M as Mobile App<br/>(Expo)
    participant H as Hono API<br/>/v1/auth/bankid/*
    participant BID as BankID OIDC

    M->>H: GET /v1/auth/bankid/initiate?platform=mobile
    Note over H: Rate limit check (10/min per IP)
    H->>H: Generate state + nonce
    H-->>M: {redirectUrl: "https://auth.bankid.no/...",<br/>state: "Z"}

    M->>M: Store state in memory
    M->>BID: Open BankID in secure browser<br/>(expo-web-browser)
    Note over M,BID: User authenticates with BankID
    BID-->>M: Deep link: drop://auth/callback<br/>?code=AUTH_CODE&state=Z

    M->>M: Verify state matches stored state
    M->>H: POST /v1/auth/bankid/callback<br/>{code: AUTH_CODE, state: Z, platform: "mobile"}
    H->>BID: POST /token (exchange code)
    BID-->>H: {id_token, access_token}
    H->>H: Verify id_token via JWKS
    H->>H: Extract pid, verify age >= 18
    H->>H: findOrCreateUser(pid, name)
    H->>H: Create session
    H->>H: Issue Drop JWT (7-day expiry)
    H-->>M: {token: "eyJ...", data: {id, name, role}}
    M->>M: Store token in AsyncStorage

3. Token Lifecycle

3.1 Drop JWT (Issued After BankID Auth)

stateDiagram-v2
    [*] --> Issued: BankID auth success<br/>→ createSession() + signJWT()
    Issued --> Active: Token in use<br/>(cookie or Bearer header)
    Active --> Active: API request<br/>→ verifySession() passes
    Active --> Expired: 7d expiry reached<br/>(all clients)
    Active --> Revoked: User logs out<br/>→ revokeAllSessions()
    Active --> Revoked: Session marked revoked<br/>(admin action)
    Expired --> Refreshed: POST /auth/refresh<br/>→ new JWT + new session
    Expired --> [*]: User must re-authenticate
    Revoked --> [*]: User must re-authenticate
    Refreshed --> Active

3.2 JWT Payload Structure

interface DropJwtPayload {
  userId: string;   // "usr_a1b2c3d4e5f6g7h8"
  email: string;    // "usr_xxx@bankid.drop.local" (placeholder)
  role: string;     // "user" | "merchant"
  iat: number;      // Issued at (Unix timestamp)
  exp: number;      // Expiry (iat + 7d, all clients)
  iss?: string;     // "drop-api" (Hono only)
  aud?: string;     // "drop" (Hono only)
}

3.3 Token Lifetimes

Platform Token Location Lifetime Refresh
Web httpOnly cookie (drop_token) 24 hours POST /api/auth/refresh
Mobile Bearer header (AsyncStorage) 7 days POST /v1/auth/refresh

3.4 Session Tracking

Every JWT has a corresponding record in the sessions table:

Column Purpose
id Session ID (ses_<hex16>)
user_id FK to users.id
token_hash SHA-256 hash of the JWT string
expires_at Token expiry timestamp
revoked 0 = active, 1 = revoked

On every authenticated request, the middleware:

  1. Extracts JWT from cookie (web) or Authorization header (mobile)
  2. Verifies JWT signature and expiry with jose.jwtVerify()
  3. Hashes the JWT with SHA-256
  4. Looks up the session by token_hash
  5. Verifies revoked = 0 and expires_at > now
  6. Rejects the request if any check fails

4. BankID Claims Mapping

4.1 ID Token Claims

BankID Claim Type Drop Usage Stored In
sub string Subject identifier (BankID internal) Fallback for pid
pid string Norwegian fødselsnummer (11 digits) Hashed as users.national_id_hash (SHA-256)
name string Full name (e.g., "Ola Nordmann") Split into users.first_name + users.last_name
birthdate string ISO date (not always present) Parsed from pid instead (more reliable)
iss string Issuer URL Validated against expected issuer
aud string Client ID Validated against BANKID_CLIENT_ID
exp number Token expiry Validated (must be in future)
iat number Issued at Validated (must be reasonable)
nonce string Anti-replay Matched against value sent in auth request

4.2 PID (Fødselsnummer) Processing

The pid (personal identification number) is an 11-digit Norwegian national ID that encodes the birthdate:

Format: DDMMYYNNNCC
  DD   = Day of birth (01-31)
  MM   = Month of birth (01-12)
  YY   = Year of birth (2 digits)
  NNN  = Individual number (000-999)
  CC   = Check digits (Luhn variant)

Century derivation (from individual number):
  NNN 000-499 → born 1900-1999
  NNN 500-999 → born 2000-2099

Source: bankid.ts:37-51 (parseBirthdateFromPid)

Processing pipeline:

  1. Parse birthdate from pid digits → ISO date string (YYYY-MM-DD)
  2. Verify age >= 18 by comparing birthdate to current date (isOver18())
  3. Hash pid with SHA-256 for storage: crypto.createHash("sha256").update(pid).digest("hex")
  4. Never store raw pid — only the hash exists in the database (users.national_id_hash)

4.3 User Deduplication

Users are identified by national_id_hash (SHA-256 of their fødselsnummer). This ensures:

Source: bankid.ts:208-249 (findOrCreateUser)


5. User Creation (Auto-Registration)

BankID login automatically creates user accounts. There is no separate registration step.

Field Value Source
id usr_<hex16> randomId("usr")
email usr_xxx@bankid.drop.local Placeholder (BankID doesn't provide email)
password_hash EIDONLY Sentinel — no password authentication
auth_provider bankid Indicates BankID-only auth
first_name First word of name claim Split from BankID name
last_name Remaining words of name claim Split from BankID name
date_of_birth Parsed from pid parseBirthdateFromPid(pid)
kyc_status approved BankID = verified identity
kyc_method bankid KYC via BankID
kyc_verified_at Current timestamp Set on creation
national_id_hash SHA-256(pid) For user deduplication
role user Default; upgradable to merchant

6. SCA Compliance

6.1 PSD2 SCA Requirements Met by BankID

PSD2 RTS Requirement BankID Implementation Status
Two of three factors (knowledge, possession, inherence) BankID app (possession) + personal code (knowledge) or biometrics (inherence) Met
Independence of factors Separate device (BankID app) + separate knowledge factor Met
Dynamic linking (for PISP) Amount and payee displayed in BankID app during payment approval Met (ASPSP-side)
Authentication code specific to amount + payee BankID generates unique code per transaction Met (ASPSP-side)
Session timeout BankID sessions expire (configurable by ASPSP) Met
Max 5 failed attempts BankID locks after failed attempts Met (BankID-side)

6.2 Drop's SCA Scope

Drop performs SCA at two levels:

  1. Drop authentication (login): BankID OIDC — satisfies SCA for account access
  2. Payment SCA (PISP): Delegated to ASPSP — user re-authenticates with BankID at the bank for each payment (see open-banking-aisp-pisp.md)

7. Error Handling

7.1 Error Matrix

Error Scenario HTTP Status Error Code User Message (Norwegian) Recovery Action
BankID auth cancelled by user 400 bankid_cancelled "Du avbrøt BankID-innlogging." Retry login
BankID auth timeout 408 bankid_timeout "BankID-sesjonen utløp. Prøv igjen." Retry login
State mismatch (CSRF) 403 state_mismatch "Sikkerhetssjekk feilet. Prøv igjen." Restart flow
Token exchange failed 502 token_exchange_failed "Kunne ikke koble til BankID. Prøv igjen." Retry later
JWKS verification failed 502 jwks_verification_failed "Teknisk feil. Prøv igjen senere." Alert ops, retry
Invalid pid format 422 invalid_pid "Ugyldig identifikasjon fra BankID." Contact support
User under 18 403 underage "Du må være minst 18 år for å bruke Drop." No recovery — legal requirement
BANKID_CLIENT_ID missing 500 config_error "Teknisk feil. Prøv igjen senere." Fix server config
Session revoked 401 session_revoked "Sesjonen din er utløpt. Logg inn på nytt." Re-authenticate
JWT expired 401 token_expired "Sesjonen din er utløpt. Logg inn på nytt." Refresh or re-authenticate
Rate limited 429 rate_limited "For mange forsøk. Vent litt og prøv igjen." Wait and retry

7.2 Error Flow

flowchart TD
    A[BankID auth attempt] --> B{Auth successful?}
    B -->|Yes| C[Exchange code for tokens]
    B -->|No: cancelled| D[Return bankid_cancelled]
    B -->|No: timeout| E[Return bankid_timeout]

    C --> F{Token exchange OK?}
    F -->|Yes| G[Verify ID token via JWKS]
    F -->|No| H[Return token_exchange_failed<br/>Log error, alert ops]

    G --> I{JWKS valid?}
    I -->|Yes| J[Extract pid from claims]
    I -->|No| K[Return jwks_verification_failed<br/>Check JWKS URL, cert rotation]

    J --> L{Valid pid format?}
    L -->|Yes| M{Age >= 18?}
    L -->|No| N[Return invalid_pid]

    M -->|Yes| O[findOrCreateUser]
    M -->|No| P[Return underage<br/>403 Forbidden]

    O --> Q[Create session + JWT]
    Q --> R[Redirect to /dashboard]

8. Mock Mode (Development)

When BANKID_MOCK=true, the OIDC flow is simulated locally without contacting BankID:

Mock Code Prefix Mock User Birthdate Age Check
underage* "Ung Testbruker" (pid: 01011012345) 2010-01-01 Fails (under 18)
(anything else) "Test Bankersen" (pid: 01019012345) 1990-01-01 Passes

Source: bankid.ts:188-195 (getMockUserInfo)

Mock flow:

  1. initiateOIDC() generates a redirect URL (same structure, but not called)
  2. Frontend skips BankID redirect, posts a mock code to callback
  3. exchangeAndVerify() detects isMock=true and returns mock user info
  4. findOrCreateUser() runs normally (creates real DB records)

9. Production Migration Checklist

Step Description Status
1 Register BankID OIDC client at developer.bankid.no Not started
2 Obtain BANKID_CLIENT_ID and BANKID_CLIENT_SECRET Not started
3 Configure callback URLs (web + mobile deep link) Not started
4 Set BANKID_MOCK=false (or remove env var) Pending
5 Test OIDC flow in BankID preprod environment Not started
6 Configure JWKS caching (jose handles this, verify TTL) Pending
7 Set secure JWT_SECRET (min 32 chars, from vault) Pending
8 Verify nonce validation in callback Implemented in code
9 Test underage rejection with real BankID test user Not started
10 Test session revocation and re-authentication Implemented in code
11 Move to production BankID endpoints Not started
12 Monitor JWKS key rotation (BankID rotates keys) Pending

10. Phase 2: Vipps Login (Planned)

Vipps Login uses the same OIDC protocol. Integration plan:

  1. Register Vipps Login client at portal.vipps.no
  2. Add Vipps OIDC endpoints alongside BankID
  3. Vipps also returns Norwegian pid (fødselsnummer)
  4. User deduplication via national_id_hash — same hash regardless of whether user logs in with BankID or Vipps
  5. UI: Login screen offers two buttons: "Logg inn med BankID" and "Logg inn med Vipps"

Optional: Use an OIDC aggregator like Idura to get a single integration point for BankID + Vipps + future providers.


11. Environment Variables

Required (Production)

Variable Description Example
BANKID_CLIENT_ID OIDC client ID from BankID abc123-def456
BANKID_CLIENT_SECRET OIDC client secret secret-from-bankid
BANKID_CALLBACK_URL Web callback URL https://getdrop.no/api/auth/bankid/callback
BANKID_CALLBACK_URL_MOBILE Mobile deep link callback drop://auth/callback
JWT_SECRET Drop JWT signing secret (min 32 chars) (from Vaultwarden)

Optional

Variable Description Default
BANKID_AUTHORIZE_URL Override authorize endpoint BankID prod URL
BANKID_TOKEN_URL Override token endpoint BankID prod URL
BANKID_JWKS_URL Override JWKS endpoint BankID prod URL
BANKID_ISSUER Override expected issuer BankID prod issuer
BANKID_MOCK Enable mock mode (true/false) false
JWT_ALGORITHM JWT signing algorithm HS256
JWT_EXPIRY Token lifetime 24h

12. Cross-References

Open Banking (AISP/PISP)

Open Banking Integration: AISP & PISP

Version: 1.0 Date: 2026-02-21 Author: Banking Architecture Team Status: Approved Applies to: Drop — PSD2 Pass-Through Model


1. Overview

Drop operates as a Third Party Provider (TPP) under PSD2, using two regulated services:

Drop never holds customer funds. All money stays in the user's bank account. Drop initiates payments on behalf of the user via PISP and reads balances via AISP, both requiring explicit user consent and Strong Customer Authentication (SCA).

TPP Registration

Before operating as AISP/PISP, Drop must:

  1. Register with Finanstilsynet (Norwegian FSA) as a payment institution or authorized agent
  2. Obtain eIDAS certificates (QWAC for TLS, QSeal for signing) from a qualified TSP
  3. Register in the EBA TPP Register for EU-wide passporting
  4. Onboard with ASPSPs (banks) via their Open Banking developer portals or through an aggregator (e.g., Neonomics, Tink, Enable Banking)
Registration Item Authority Status
Finanstilsynet PISP/AISP license Finanstilsynet Not applied (Phase 2 blocker)
eIDAS QWAC certificate Qualified TSP (e.g., Buypass, Commfides) Not obtained
eIDAS QSeal certificate Qualified TSP Not obtained
EBA TPP Register entry EBA Pending license
ASPSP onboarding (DNB, SpareBank 1, Nordea) Per bank Pending license

Interim approach: Drop can operate as an agent under a licensed PSP's umbrella (1-3 months to set up) while preparing its own license application (6-12 months).


2. Berlin Group NextGenPSD2 API Standard

Drop targets the Berlin Group NextGenPSD2 specification (v1.3.12+), which is the dominant Open Banking standard in Scandinavia and the EEA. Norwegian banks (DNB, SpareBank 1, Nordea) expose APIs conforming to this standard, often through the BITS (Banking Industry's Technical Secretariat) coordination framework.

2.1 API Endpoint Mapping

Berlin Group Endpoint Method Drop Usage Drop Feature
/v1/consents POST Create AISP consent Bank Account Linking (/accounts)
/v1/consents/{consentId} GET Check consent status Consent lifecycle management
/v1/consents/{consentId} DELETE Revoke consent Settings / Account unlinking
/v1/consents/{consentId}/authorisations POST Start SCA for consent Bank linking SCA redirect
/v1/consents/{consentId}/status GET Poll consent status Post-SCA consent verification
/v1/accounts GET List user's bank accounts Dashboard account selector
/v1/accounts/{accountId} GET Get account details Bank Account detail view
/v1/accounts/{accountId}/balances GET Read balance Dashboard balance display (bank_accounts.balance)
/v1/accounts/{accountId}/transactions GET Read transaction history Transaction History (future)
/v1/payments/sepa-credit-transfers POST Initiate SEPA payment Remittance (EEA corridors)
/v1/payments/cross-border-credit-transfers POST Initiate cross-border payment Remittance (non-EEA corridors)
/v1/payments/domestic-credit-transfers POST Initiate Norwegian payment QR Payment to merchant
/v1/payments/{paymentId} GET Check payment status Transaction status tracking
/v1/payments/{paymentId}/authorisations POST Start SCA for payment Payment SCA redirect
/v1/payments/{paymentId}/status GET Poll payment status Post-SCA payment verification

2.2 Base URLs (Norwegian Banks)

Bank (ASPSP) Sandbox URL Production URL
DNB https://developer.dnb.no/sandbox/psd2/ https://api.dnb.no/psd2/
SpareBank 1 https://developer.sparebank1.no/sandbox/ https://api.sparebank1.no/open-banking/
Nordea https://developer.nordeaopenbanking.com/ https://api.nordeaopenbanking.com/

3. AISP — Account Information Service

AISP access requires explicit user consent. The consent specifies which accounts and data types (balances, transactions) the TPP can access, and has a maximum validity of 90 days (per PSD2 RTS Art. 10).

sequenceDiagram
    participant U as User (Browser/App)
    participant D as Drop API
    participant A as ASPSP (User's Bank)

    U->>D: Link bank account
    D->>A: POST /v1/consents<br/>{access: {balances: [iban], transactions: [iban]},<br/>recurringIndicator: true, validUntil: "2026-08-21",<br/>frequencyPerDay: 4, combinedServiceIndicator: false}
    A-->>D: 201 {consentId, consentStatus: "received",<br/>_links: {scaRedirect: "https://bank.no/sca/..."}}
    D-->>U: Redirect to bank SCA page
    U->>A: Authenticate with BankID (SCA)
    A-->>U: Redirect to Drop callback
    U->>D: GET /api/accounts/callback?consentId=xxx
    D->>A: GET /v1/consents/{consentId}/status
    A-->>D: {consentStatus: "valid"}
    D->>A: GET /v1/accounts?consentId={consentId}
    A-->>D: {accounts: [{iban, currency, name, ...}]}
    D->>A: GET /v1/accounts/{accountId}/balances
    A-->>D: {balances: [{balanceType: "expected", balanceAmount: {currency: "NOK", amount: "45230.00"}}]}
    D->>D: Store in bank_accounts table<br/>(balance cached, balance_synced_at = now)
    D-->>U: Bank account linked successfully

3.2 Balance Retrieval

Refresh strategy:

Trigger Frequency Method
User opens Dashboard On demand GET /v1/accounts/{id}/balances
Background sync Every 6 hours (max 4/day per PSD2 RTS) Scheduled job per linked account
Pre-payment check Before PISP initiation GET /v1/accounts/{id}/balances
User manual refresh Pull-to-refresh gesture GET /v1/accounts/{id}/balances

PSD2 RTS constraint: A TPP may access the ASPSP's account data a maximum of 4 times per day without the PSU's active request (Art. 36(6) RTS). User-initiated requests are unlimited.

Drop API mapping:

3.3 Data Storage

AISP Data Drop DB Column Table Notes
Account IBAN bank_accounts.iban bank_accounts Stored on linking
Account name bank_accounts.bank_name bank_accounts ASPSP name (e.g., "DNB")
Account number bank_accounts.account_number bank_accounts Domestic format
Balance amount bank_accounts.balance bank_accounts Cached AISP read (stored in oere)
Balance timestamp bank_accounts.balance_synced_at bank_accounts Last refresh time
Consent ID (new column needed) bank_accounts or consents Links to ASPSP consent
Consent expiry (new column needed) consents Max 90 days from grant

4. PISP — Payment Initiation Service

4.1 Payment Initiation Flow

PISP initiates payments from the user's bank account. Every PISP transaction requires SCA with dynamic linking — the authentication must be tied to the specific amount and payee (PSD2 RTS Art. 97(2)).

sequenceDiagram
    participant U as User (Browser/App)
    participant D as Drop API
    participant A as ASPSP (User's Bank)
    participant R as Recipient Bank

    U->>D: POST /api/transactions/remittance<br/>{recipientId, amount, bankAccountId}
    D->>D: Validate: KYC approved, balance sufficient,<br/>amount 100-50000 NOK, exchange rate lookup
    D->>D: POST /api/transactions/disclosure<br/>Calculate fee (0.5%), FX rate, total
    D-->>U: Show pre-payment disclosure<br/>(PSD2 Art. 45/46 compliance)
    U->>D: Confirm payment
    D->>A: POST /v1/payments/sepa-credit-transfers<br/>{debtorAccount: {iban},<br/>instructedAmount: {currency, amount},<br/>creditorAccount: {iban}, creditorName,<br/>remittanceInformationUnstructured}
    A-->>D: 201 {paymentId, transactionStatus: "RCVD",<br/>_links: {scaRedirect: "https://bank.no/sca/pay/..."}}
    D->>D: Create transaction record<br/>(status: "processing", idempotency_key set)
    D-->>U: Redirect to bank SCA page
    U->>A: Authenticate payment with BankID<br/>(dynamic linking: amount + payee shown)
    A-->>U: Redirect to Drop callback
    U->>D: GET /api/payments/callback?paymentId=xxx
    D->>A: GET /v1/payments/{paymentId}/status
    A-->>D: {transactionStatus: "ACCP"}
    D->>D: Update transaction status to "completed"
    D-->>U: Payment confirmed
    Note over A,R: Bank executes SEPA/SWIFT transfer<br/>Settlement via interbank rails

4.2 Payment Types

Drop Transaction Type Berlin Group Payment Product Use Case Settlement Time
QR Payment (domestic) domestic-credit-transfers Pay merchant in Norway Instant (SEPA Inst) or T+1
Remittance (EEA) sepa-credit-transfers Send to EU/EEA countries 1-2 business days
Remittance (non-EEA) cross-border-credit-transfers Send to Serbia, Pakistan, Turkey, etc. 2-4 business days

4.3 Dynamic Linking (PSD2 RTS Art. 97(2))

For every PISP payment, the SCA procedure must incorporate dynamic linking:

Implementation: Drop passes instructedAmount and creditorName in the PISP request. The ASPSP displays these on the BankID SCA screen. The user confirms by authenticating, creating a cryptographic link between the consent and the specific transaction parameters.

4.4 Idempotency

Drop uses the idempotency_key column in the transactions table (with a unique index idx_tx_idempotency) to prevent duplicate payments:

  1. Generate idempotency_key from: {userId}:{recipientId}:{amount}:{timestamp_minute}
  2. Include as X-Request-ID header in PISP API calls
  3. If ASPSP returns a duplicate error, look up existing transaction by idempotency_key
  4. Return the existing transaction to the user (no double-charge)

5.1 State Diagram

stateDiagram-v2
    [*] --> Requested: User initiates bank linking
    Requested --> ScaPending: ASPSP returns scaRedirect
    ScaPending --> Valid: User completes SCA
    ScaPending --> Failed: SCA timeout / user cancels
    ScaPending --> Failed: SCA rejected by bank
    Valid --> Valid: Balance refresh (within 90 days)
    Valid --> Expired: 90-day validity exceeded
    Valid --> RevokedByUser: User unlinks bank account
    Valid --> RevokedByASPSP: Bank revokes access
    Valid --> RenewalPending: 90 days before expiry, prompt renewal
    RenewalPending --> ScaPending: User re-authenticates
    RenewalPending --> Expired: User ignores renewal
    Expired --> Requested: User re-links account
    Failed --> Requested: User retries
    RevokedByUser --> [*]
    RevokedByASPSP --> Requested: User re-links
    Expired --> [*]
Property Value PSD2 Reference
Maximum validity 90 days RTS Art. 10(1)
Renewal SCA required Yes, every 90 days RTS Art. 10(2)
Access frequency (TPP-initiated) Max 4x/day per account RTS Art. 36(6)
Access frequency (PSU-initiated) Unlimited RTS Art. 36(6)
Revocation User can revoke at any time PSD2 Art. 94
Scope Per-account (balances + transactions) Berlin Group consent model
Combined service false (AISP separate from PISP) Berlin Group combinedServiceIndicator

Drop tracks consents in two places:

  1. consents table — GDPR consent records (consent_type: psd2_aisp, psd2_pisp)
  2. bank_accounts table — Links to ASPSP consent ID for each linked account

6. SCA Requirements

6.1 When SCA Is Required

Operation SCA Required SCA Type
AISP consent creation Yes Redirect to bank (BankID)
AISP consent renewal (90 days) Yes Redirect to bank (BankID)
AISP balance read (after initial consent) No Access token sufficient
PISP payment initiation Yes, always Redirect to bank with dynamic linking
PISP payment > threshold Yes (no exemption) Drop does not apply SCA exemptions

6.2 SCA Methods

Drop does not perform SCA directly. SCA is delegated to the ASPSP (user's bank), which uses BankID as the authentication mechanism. The flow is:

  1. Drop sends AISP consent or PISP payment request to ASPSP
  2. ASPSP returns an scaRedirect URL
  3. Drop redirects the user to the bank's SCA page
  4. User authenticates with BankID (knowledge + possession factors)
  5. Bank redirects back to Drop with the result

6.3 SCA Exemptions

Drop does not apply SCA exemptions for PISP transactions. All payments require full SCA regardless of amount. This is a conservative approach that:


7. Fallback Mechanisms

7.1 ASPSP API Unavailability

If the ASPSP's dedicated PSD2 API is unavailable:

Scenario Fallback PSD2 Reference
API down (AISP) Show last cached balance with timestamp RTS Art. 33(4)
API down (PISP) Display error, suggest retry later No fallback — payment requires live API
API degraded (slow) 30s timeout, retry once Standard HTTP retry
API returns 5xx Circuit breaker (3 failures → 60s cooldown) Operational resilience
Consent expired Prompt user to re-authenticate Renewal flow

7.2 Fallback Access (Screen Scraping)

Under PSD2 RTS Art. 33(4), if an ASPSP's dedicated API does not meet availability and performance standards, the TPP may fall back to the ASPSP's customer-facing online banking interface. Drop does not plan to implement screen scraping — instead relying on aggregators (Neonomics, Tink) who handle multi-bank connectivity and fallback.

7.3 Multi-Bank Strategy

Norwegian users may have accounts at multiple banks. Drop supports multiple linked accounts via the bank_accounts table (one marked is_primary). Each linked account has its own AISP consent with its own 90-day lifecycle.


8. Error Handling

8.1 ASPSP Error Codes (Berlin Group)

HTTP Status Berlin Group Code Drop Handling User Message
400 FORMAT_ERROR Log + show validation error "Kunne ikke koble til banken. Sjekk kontonummeret."
401 CERTIFICATE_INVALID Alert ops, block requests "Teknisk feil. Prøv igjen senere."
403 CONSENT_INVALID Delete consent, prompt re-link "Tilgangen til banken din er utløpt. Koble til på nytt."
403 CONSENT_EXPIRED Delete consent, prompt re-link "Tilgangen til banken din er utløpt. Koble til på nytt."
404 RESOURCE_UNKNOWN Log + remove stale data "Kontoen ble ikke funnet i banken."
429 ACCESS_EXCEEDED Back off, use cached data "For mange forespørsler. Viser sist kjente saldo."
500+ Server error Circuit breaker, use cached data "Banken svarer ikke. Prøv igjen senere."

8.2 Payment-Specific Errors

Scenario ASPSP Response Drop Handling
Insufficient funds RJCT (rejected) Update transaction status to failed, notify user
Invalid IBAN FORMAT_ERROR Reject before sending to ASPSP (validate locally)
SCA timeout No callback within 5 min Mark transaction as failed, release hold
SCA cancelled User cancels at bank Mark transaction as failed
Duplicate payment DUPLICATE or HTTP 409 Look up by idempotency_key, return existing

9. Aggregator Strategy

Rather than integrating directly with each ASPSP, Drop plans to use an Open Banking aggregator for production:

Aggregator Coverage Strengths Consideration
Neonomics (Norwegian) Nordics + EEA Strong Nordic bank coverage, Norwegian company Primary candidate
Tink (Visa) EU-wide (6000+ banks) Largest coverage, Visa backing Broader coverage
Enable Banking Nordics Direct PSD2, no screen scraping Privacy-focused

Benefits of aggregator approach:

Trade-offs:


10. Implementation Roadmap

Phase Milestone Dependencies
Current (MVP) Mock AISP/PISP (local DB balances, simulated payments) None
Phase 2a Aggregator sandbox integration (Neonomics or Tink) Aggregator contract signed
Phase 2b BankID OIDC for Drop auth + ASPSP SCA for consents BankID client credentials
Phase 2c AISP live (read real balances from DNB, SpareBank 1) eIDAS cert + consent flow
Phase 2d PISP live (initiate real payments) Finanstilsynet license or agent arrangement
Phase 3 Multi-bank support, consent renewal automation, SEPA Instant Production scaling

11. Cross-References

Payment Processing

Payment Processing Architecture

Version: 1.0 Date: 2026-02-21 Author: Banking Architecture Team Status: Approved Applies to: Drop — Payment Initiation & Settlement


1. Overview

Drop processes two types of payments, both initiated via PISP (Payment Initiation Service Provider) from the user's own bank account:

  1. Remittance — cross-border money transfers to 30+ countries (via SEPA SCT/SCTInst for EEA, SWIFT gpi for non-EEA)
  2. QR Payment — instant domestic payments to merchants in Norway

Drop never holds customer funds. All payments are initiated directly from the user's bank account via PSD2 Open Banking APIs. Drop earns revenue from transaction fees (0.5% remittance, 1% QR).

Property Remittance QR Payment
API endpoint POST /api/transactions/remittance POST /api/transactions/qr-payment
Fee 0.5% of send amount 1.0% of payment amount
Amount range 100 - 50,000 NOK 1 - 100,000 NOK
Settlement rail SEPA SCT/SCTInst (EEA), SWIFT (non-EEA) Domestic credit transfer (instant)
KYC required Yes (kyc_status = 'approved') No (auth sufficient)
FX conversion Yes (NOK to recipient currency) No (NOK to NOK)
Status flow processing -> completed / failed completed (instant)

2. SEPA Payment Flow (EEA Remittance)

For remittances to EEA countries (EU + Norway, Iceland, Liechtenstein), Drop uses SEPA Credit Transfer (SCT) or SEPA Instant Credit Transfer (SCT Inst).

2.1 SEPA SCT Flow

sequenceDiagram
    participant U as User
    participant D as Drop API
    participant DB as Drop DB
    participant A as User's Bank (ASPSP)
    participant CSM as SEPA CSM<br/>(EBA CLEARING / TARGET2)
    participant RB as Recipient Bank

    U->>D: POST /api/transactions/remittance<br/>{recipientId, amount: 2000, bankAccountId}
    D->>DB: Verify: KYC approved, recipient exists,<br/>bank account exists
    D->>DB: GET exchange_rates WHERE to_currency = 'EUR'
    D->>D: Calculate fee: 2000 * 0.005 = 10 NOK<br/>Calculate receive: 2000 * 0.087 = 174 EUR<br/>Total debit: 2010 NOK

    D->>D: POST /api/transactions/disclosure<br/>(PSD2 Art. 45/46 pre-payment info)
    D-->>U: Disclosure: send 2000 NOK, fee 10 NOK,<br/>rate 0.087, receive 174 EUR,<br/>ETA 1-2 business days

    U->>D: Confirm payment
    D->>DB: Generate idempotency_key<br/>INSERT transaction (status: processing)
    D->>A: POST /v1/payments/sepa-credit-transfers<br/>{debtorAccount: {iban: user_iban},<br/>instructedAmount: {currency: NOK, amount: 2010},<br/>creditorAccount: {iban: recipient_iban},<br/>creditorName: "Recipient Name"}
    A-->>D: {paymentId, transactionStatus: RCVD,<br/>scaRedirect: "https://bank.no/sca/..."}
    D-->>U: Redirect to bank SCA

    U->>A: BankID authentication (dynamic linking:<br/>amount 2010 NOK to "Recipient Name")
    A-->>U: Redirect to Drop callback
    U->>D: Payment callback
    D->>A: GET /v1/payments/{paymentId}/status
    A-->>D: {transactionStatus: ACCP}
    D->>DB: UPDATE transaction SET status = 'completed'

    Note over A,CSM: Bank submits to SEPA CSM<br/>within cutoff time
    A->>CSM: SEPA SCT message (pacs.008)
    CSM->>RB: Route to recipient bank
    RB->>RB: Credit recipient account
    Note over CSM,RB: Settlement: T+1 business day<br/>(SCT Inst: < 10 seconds)

2.2 SEPA Specifications

Property SEPA SCT SEPA SCT Inst
Standard ISO 20022 pacs.008 ISO 20022 pacs.008
Max amount 999,999,999.99 EUR 100,000 EUR
Settlement time T+1 business day < 10 seconds (24/7/365)
Availability Business days only 24/7/365
Coverage 36 SEPA countries Participating banks only
Drop usage Default for EEA remittance Preferred when available
Cut-off time Bank-specific (typically 14:00-16:00 CET) No cut-off

3. Cross-Border Remittance with FX

For non-EEA corridors (Serbia, Pakistan, Turkey, etc.), Drop uses SWIFT gpi (Global Payments Innovation) or correspondent banking networks.

3.1 Cross-Border Flow with FX Conversion

sequenceDiagram
    participant U as User
    participant D as Drop API
    participant FX as FX Rate Provider
    participant A as User's Bank (ASPSP)
    participant CB as Correspondent Bank
    participant RB as Recipient Bank<br/>(e.g., Banca Intesa, Serbia)

    U->>D: POST /api/transactions/remittance<br/>{recipientId: rec_1, amount: 2000}
    D->>D: Lookup recipient: Marko Petrovic,<br/>Serbia, RSD, Banca Intesa

    D->>FX: GET current NOK/RSD rate
    FX-->>D: Rate: 10.17 (1 NOK = 10.17 RSD)

    D->>D: Calculate:<br/>Send: 2000 NOK<br/>Fee: 2000 * 0.005 = 10 NOK<br/>Receive: 2000 * 10.17 = 20,340 RSD<br/>Total debit: 2010 NOK

    D-->>U: Disclosure (PSD2 Art. 45):<br/>You send: 2,000 NOK<br/>Fee: 10 NOK (0.5%)<br/>Rate: 1 NOK = 10.17 RSD<br/>Recipient receives: 20,340 RSD<br/>Total cost: 2,010 NOK<br/>ETA: 2-4 business days

    U->>D: Confirm payment
    D->>D: Lock FX rate for 15 minutes<br/>(rate_locked_at = now, rate_expires_at = now + 15m)
    D->>D: Generate idempotency_key<br/>INSERT transaction (status: processing)

    D->>A: POST /v1/payments/cross-border-credit-transfers<br/>{debtorAccount: {iban}, instructedAmount: {NOK, 2010},<br/>creditorAccount: {bban: recipient_bank_account},<br/>creditorName: "Marko Petrovic",<br/>creditorAgent: {bic: DBDBRSBG}}
    A-->>D: {paymentId, scaRedirect}
    D-->>U: Redirect to bank SCA

    U->>A: BankID authentication
    A-->>U: Redirect to Drop callback
    D->>A: GET /v1/payments/{paymentId}/status
    A-->>D: {transactionStatus: ACCP}
    D->>D: UPDATE transaction status = 'completed'

    Note over A,CB: SWIFT gpi transfer<br/>UETR tracking ID assigned
    A->>CB: MT103 / pacs.008 (NOK)
    CB->>CB: FX conversion NOK to RSD<br/>(at correspondent bank rate)
    CB->>RB: Credit in RSD
    RB->>RB: Credit Marko's account<br/>20,340 RSD received

3.2 Supported Corridors

Corridor Currency Exchange Rate (NOK to) Rail Estimated Delivery
Norway to Serbia RSD 10.17 SWIFT gpi 2-4 business days
Norway to Bosnia BAM 0.17 SWIFT gpi 2-4 business days
Norway to Poland PLN 0.374 SEPA SCT (EEA) 1-2 business days
Norway to Pakistan PKR 26.5 SWIFT gpi 2-4 business days
Norway to Turkey TRY 3.39 SWIFT gpi 2-4 business days
Norway to EU (EUR) EUR 0.087 SEPA SCT/SCTInst 1-2 days / instant

Source: exchange_rates table, seeded in db.ts:234-237


4. FX Rate Management

4.1 Rate Sourcing

Phase Source Refresh Markup
MVP (current) Static seed data in exchange_rates table Manual update None (display rate = mid-market)
Phase 2 ECB reference rates + commercial FX provider Every 15 minutes 0.1-0.3% spread
Phase 3 Real-time feed from FX partner (e.g., Wise, CurrencyCloud) Real-time (streaming) Configurable per corridor

4.2 Rate Lock Window

When a user initiates a remittance, the FX rate is locked for 15 minutes:

  1. User sees rate on the disclosure screen
  2. Rate is locked when user confirms (before SCA)
  3. If SCA completes within 15 minutes, the locked rate applies
  4. If SCA times out, the rate expires and must be re-quoted

This protects both the user (no surprise rate changes during authentication) and Drop (limited exposure to rate movement).

4.3 Rate Storage

Column Table Description
exchange_rates.rate exchange_rates Current mid-market rate (NOK to target)
exchange_rates.updated_at exchange_rates Last rate update timestamp
transactions.exchange_rate transactions Rate locked at transaction time
transactions.send_amount transactions Amount in NOK (stored in oere)
transactions.receive_amount transactions Amount in target currency (stored in subunits)

5. Fee Calculation Model

5.1 Fee Structure

Transaction Type Fee Rate Min Fee Max Fee Applied To
Remittance 0.5% 10 NOK 500 NOK Send amount (before FX)
QR Payment 1.0% 1 NOK 1,000 NOK Payment amount
AISP balance read Free - - No charge

5.2 Fee Calculation

Remittance example (2,000 NOK to Serbia):

Line Item Calculation Amount
Send amount User input 2,000.00 NOK
Fee (0.5%) 2,000 * 0.005 10.00 NOK
Total debit Send + Fee 2,010.00 NOK
Exchange rate From exchange_rates table 10.17 RSD/NOK
Receive amount 2,000 * 10.17 20,340.00 RSD

QR Payment example (149 NOK at merchant):

Line Item Calculation Amount
Payment amount From QR scan 149.00 NOK
Fee (1.0%) 149 * 0.01 1.49 NOK
Total debit Payment + Fee 150.49 NOK
Merchant receives Payment - merchant fee 147.51 NOK

5.3 Fee Code References

Endpoint Fee Logic Source
POST /api/transactions/remittance fee = amount * 0.005 transactions/remittance/route.ts
POST /api/transactions/qr-payment fee = amount * 0.01 transactions/qr-payment/route.ts
POST /api/transactions/disclosure Returns fee + FX pre-payment transactions/disclosure/route.ts
GET /api/rates/[currency] Returns fee: 0.005 (informational) rates/[currency]/route.ts

5.4 Revenue Model Comparison

Provider Remittance Fee QR/In-Store Fee
Drop 0.5% 1.0%
Western Union 5-10% N/A
Wise 0.7-1.5% N/A
Vipps N/A 1.75-2.75%

6. Settlement & Reconciliation

6.1 Settlement Flow

Drop does not settle payments itself — the ASPSP (user's bank) handles settlement via interbank rails. Drop's role is to initiate and track payments.

Step Actor Action
1. Initiation Drop POST PISP request to ASPSP
2. SCA User + ASPSP User authenticates at bank
3. Acceptance ASPSP Bank accepts payment instruction
4. Clearing CSM (SEPA) / SWIFT Message routed to recipient bank
5. Settlement Central bank / CSM Funds transferred between banks
6. Credit Recipient bank Recipient account credited
7. Confirmation Drop Poll payment status, update transaction

6.2 Reconciliation Process

flowchart TD
    A[Scheduled reconciliation job<br/>runs every hour] --> B{Fetch transactions<br/>WHERE status = 'processing'<br/>AND created_at older than 1h}
    B --> C[For each pending transaction]
    C --> D[GET /v1/payments/paymentId/status<br/>from ASPSP]
    D --> E{ASPSP status?}

    E -->|ACSC / ACCP| F[UPDATE status = 'completed'<br/>SET completed_at = now]
    E -->|RJCT| G[UPDATE status = 'failed'<br/>Log rejection reason]
    E -->|PDNG / ACTC| H[Keep as 'processing'<br/>Check again next cycle]
    E -->|API error| I[Log error, retry next cycle<br/>Circuit breaker if repeated]

    F --> J[Create notification:<br/>'Overfoering fullfoert']
    G --> K[Create notification:<br/>'Overfoering feilet'<br/>+ refund logic]

    K --> L{Funds already debited?}
    L -->|Yes| M[Initiate refund via ASPSP<br/>or manual intervention]
    L -->|No| N[No action needed<br/>Payment was never executed]

6.3 ASPSP Transaction Statuses (Berlin Group)

Status Code Meaning Drop Action
RCVD Received (payment accepted for processing) Transaction created, status = processing
PDNG Pending (awaiting SCA or bank processing) Keep as processing
ACTC Accepted Technical (technical validation passed) Keep as processing
ACCP Accepted Customer Profile (customer checks passed) Keep as processing
ACSC Accepted Settlement Completed Update to completed
ACSP Accepted Settlement In Process Keep as processing
RJCT Rejected Update to failed
CANC Cancelled Update to failed

7. Idempotency & Retry Strategy

7.1 Idempotency

The transactions table has a unique index on idempotency_key (idx_tx_idempotency):

CREATE UNIQUE INDEX IF NOT EXISTS idx_tx_idempotency
  ON transactions(idempotency_key)
  WHERE idempotency_key IS NOT NULL;

Key generation: {userId}:{recipientId|merchantId}:{amount}:{timestamp_minute}

Flow:

  1. Before creating a transaction, check if idempotency_key already exists
  2. If exists, return the existing transaction (no duplicate)
  3. If not, insert new transaction with the key
  4. Pass the same key as X-Request-ID to the ASPSP

7.2 Retry Strategy

Failure Type Retry? Strategy
Network timeout to ASPSP Yes Exponential backoff: 1s, 2s, 4s (max 3 retries)
ASPSP returns 5xx Yes Exponential backoff with jitter, max 3 retries
ASPSP returns 4xx No Log error, fail immediately (client error)
SCA timeout No Mark as failed, user must restart
Duplicate detected No Return existing transaction
FX rate expired No Re-quote rate, user must re-confirm

8. Pre-Payment Disclosure (PSD2 Art. 45/46)

Before initiating any payment, Drop must provide the user with clear information about costs and delivery. The POST /api/transactions/disclosure endpoint generates this.

8.1 Disclosure Content

Information Item PSD2 Article Drop Implementation
Total amount debited Art. 45(1)(a) totalCost = amount + fee
Fee amount and percentage Art. 45(1)(b) fee, feePercentage
Exchange rate applied Art. 45(1)(c) exchangeRate
Amount received by recipient Art. 45(1)(d) receiveAmount in receiveCurrency
Estimated delivery time Art. 45(1)(e) estimatedDelivery
Currency of debit Art. 45(1)(f) Send currency (NOK)
Currency of credit Art. 45(1)(g) Receive currency

8.2 Disclosure API Response

{
  "amount": 2000,
  "fee": 10,
  "feePercentage": 0.5,
  "exchangeRate": 10.17,
  "receiveAmount": 20340,
  "receiveCurrency": "RSD",
  "estimatedDelivery": "2-4 business days",
  "totalCost": 2010
}

8.3 Delivery Time Estimates

Transaction Type Corridor Estimate
QR Payment Domestic (Norway) "Instant"
Remittance EEA (SEPA) "1-2 business days"
Remittance Non-EEA (SWIFT) "2-4 business days"

9. Transaction Integrity

9.1 Atomic Operations

All financial operations use database transactions to ensure atomicity. The transaction() function in db.ts:123-179 wraps operations in BEGIN/COMMIT blocks with automatic ROLLBACK on error.

Key integrity checks:

9.2 Consistency Guarantees

Guarantee Mechanism
No double-spend WHERE balance >= ? in UPDATE + idempotency_key
No partial transactions PostgreSQL BEGIN/COMMIT
No phantom reads PostgreSQL MVCC snapshot isolation; use SERIALIZABLE isolation for full phantom read protection
No duplicate payments Unique index on idempotency_key
No stale balances balance_synced_at tracking + pre-payment AISP refresh

10. Monitoring & Alerts

10.1 Key Metrics

Metric Threshold Alert
Transaction success rate < 95% over 1 hour Critical alert
Average settlement time (SEPA) > 48 hours Warning
Average settlement time (SWIFT) > 5 business days Warning
Failed transaction rate > 5% Warning
Reconciliation mismatches Any Immediate alert
FX rate staleness > 1 hour since last update Warning

10.2 Audit Trail

All payment operations are logged in the audit_log table:

Action Logged Data
payment.initiated Transaction ID, amount, recipient, bank account
payment.sca_completed Transaction ID, SCA method
payment.completed Transaction ID, ASPSP status, settlement reference
payment.failed Transaction ID, failure reason, ASPSP error
payment.refund Original transaction ID, refund amount

11. Cross-References

Sentry Observability

Sentry Observability Integration

Error tracking, performance monitoring, alerting, and SLO/SLI definitions for the Drop fintech platform. Covers Sentry SDK integration, data scrubbing, Slack alerting, structured logging, and release tracking.


Observability Data Flow

flowchart LR
    subgraph DropAPI["drop-api (Hono v4)"]
        EH[Error Handler<br/>middleware/error-handler.ts]
        Logger[Structured Logger<br/>lib/logger.ts]
        SentryLib[Sentry SDK<br/>lib/sentry.ts]
        AlertLib[Alert System<br/>lib/alerts.ts]
    end

    subgraph SentryCloud["Sentry Cloud"]
        Issues[Issue Tracking]
        Perf[Performance Monitoring<br/>Transaction traces]
        Releases[Release Tracking<br/>Source maps + commits]
    end

    subgraph Alerting["Alert Channels"]
        Slack[Slack Webhook<br/>SLACK_WEBHOOK_URL]
        Console[stdout<br/>JSON structured logs]
    end

    subgraph Monitoring["Infrastructure"]
        CloudWatch[AWS CloudWatch<br/>Container logs + metrics]
    end

    EH -->|Unhandled errors| SentryLib
    EH -->|5xx errors| AlertLib
    EH -->|All errors| Logger
    SentryLib -->|captureException| Issues
    SentryLib -->|Transaction traces| Perf
    SentryLib -->|Release metadata| Releases
    AlertLib -->|Error spikes| Slack
    AlertLib -->|Startup/shutdown| Slack
    Logger -->|JSON log entries| Console
    Console -->|Container stdout| CloudWatch

    SentryLib -.->|beforeSend| Scrubber[Data Scrubber<br/>Removes PII, auth tokens,<br/>fødselsnummer, passwords]

Sentry Configuration

SDK Setup

Source: src/drop-api/src/lib/sentry.ts Package: @sentry/node Initialization: Lazy — initialized on first captureError() or captureMessage() call.

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  environment: process.env.NODE_ENV || "development",
  release: `drop-api@${version}`,  // from package.json
  tracesSampleRate: parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE || "0.1"),
  beforeSend(event, hint) {
    // Scrub sensitive data before sending to Sentry
    // See "Data Scrubbing" section below
  },
});

Environment Variables

Variable Required Default Description
SENTRY_DSN No Not set (no-op mode) Sentry project DSN. When absent, all Sentry calls are no-ops and errors are only logged to console.
SENTRY_TRACES_SAMPLE_RATE No 0.1 (10%) Percentage of transactions to trace for performance monitoring.
NODE_ENV No development Maps to Sentry environment tag.
SLACK_WEBHOOK_URL No Not set (skip alerts) Slack incoming webhook for alert notifications.

Sentry Project Structure

Project Environment Purpose
drop-api (staging) staging Pre-release error tracking, higher trace sample rate (0.5)
drop-api (production) production Production error tracking, 10% trace sample rate
drop-web (planned) production Client-side error tracking via @sentry/nextjs
drop-mobile (planned) production React Native error tracking via @sentry/react-native

Data Scrubbing

Source: sentry.ts:108-139

All events pass through a beforeSend hook that removes sensitive data before transmission to Sentry.

String Scrubbing Patterns

Pattern Replacement Example
password=... password=[REDACTED] Request bodies, exception messages
pin=... pin=[REDACTED] Card PIN values
cardNumber=... cardNumber=[REDACTED] Card numbers (PCI-DSS)
cvv=... cvv=[REDACTED] Card verification values
fødselsnummer=... fødselsnummer=[REDACTED] Norwegian national ID (PII)
authorization:... authorization:[REDACTED] JWT tokens
cookie:... cookie:[REDACTED] Session cookies

Object Scrubbing

Sensitive keys are recursively scrubbed from event extra data, breadcrumbs, and request bodies:

Request Header Removal

The following headers are deleted from Sentry events entirely:


Sentry SDK Functions

Source: sentry.ts:141-244

Function Purpose When Used
captureError(error, context?) Capture exception with optional user ID, request ID, tags, and extra data. Returns Sentry event ID. error-handler.ts on 5xx errors
captureMessage(message, level?, context?) Capture informational message with severity level. Manual diagnostic logging
setUser(userId, metadata?) Associate current scope with user ID for error grouping. After successful authentication
clearUser() Remove user association from current scope. On logout
addBreadcrumb(message, category, level?, data?) Add navigation/action breadcrumb for error context. Data is scrubbed. Key user actions
setTag(key, value) Set tag on current scope for filtering. Environment, feature flags
flush(timeout?) Flush pending events on graceful shutdown. Default 2000ms. Process exit handler
isSentryEnabled() Check if SENTRY_DSN is set. Conditional logic

Dual Output Pattern

All Sentry functions also log to console.error / console.log. This ensures errors are captured in CloudWatch logs even if Sentry is unavailable, and provides a local development experience without requiring a Sentry DSN.


Alert System

Source: src/drop-api/src/lib/alerts.ts

Slack Alerting

Alerts are sent to a Slack channel via incoming webhook (SLACK_WEBHOOK_URL).

Alert Type Severity Trigger Cooldown
Error spike critical 5+ errors within 60 seconds 10 minutes per unique title
API startup info Application startup None
API shutdown info Graceful shutdown None

Error Spike Detection

Source: alerts.ts:65-83

Window: 60 seconds (sliding)
Threshold: 5 errors
Cooldown: 10 minutes per alert title

The trackError() function is called from the global error handler (error-handler.ts:10) for every 5xx error. It maintains a sliding window of error timestamps and triggers a Slack alert when the spike threshold is breached.

Alert Message Format

CRITICAL *Error spike detected*
7 errors in last minute
_2026-02-21T12:00:00.000Z_
View in Sentry (link if available)

Structured Logging

Source: src/drop-api/src/lib/logger.ts

Log Entry Format

All logs are written to stdout as newline-delimited JSON (NDJSON):

{
  "timestamp": "2026-02-21T12:00:00.000Z",
  "level": "info",
  "message": "BankID login successful",
  "requestId": "550e8400-e29b-41d4-a716-446655440000",
  "metadata": {
    "userId": "usr_a1b2c3d4e5f6g7h8",
    "method": "bankid"
  }
}

Log Levels

Level Usage Example
debug Detailed diagnostic info SQL query parameters
info Normal operations Login success, transaction created
warn Recoverable issues Token verification failed, rate limit approached
error Errors requiring attention BankID callback error, database connection failure

Request Context

Each request gets a unique requestId (from x-request-id header or crypto.randomUUID()). This ID is:

  1. Set as a Hono context variable (app.ts:34)
  2. Returned in the x-request-id response header (app.ts:36)
  3. Included in all log entries for the request
  4. Attached as a Sentry tag for cross-referencing

Error Handling Flow

sequenceDiagram
    participant Client
    participant Route as Route Handler
    participant ErrHandler as Global Error Handler
    participant Logger as Structured Logger
    participant Sentry as Sentry SDK
    participant Alerts as Alert System
    participant Slack as Slack Webhook

    Route->>Route: Business logic throws error

    alt HTTPException
        Route-->>ErrHandler: HTTPException (known status)
        ErrHandler->>Logger: Log error with request context
        ErrHandler-->>Client: { error: "http_error", message: "..." }, status
    else Unhandled Error
        Route-->>ErrHandler: Error thrown
        ErrHandler->>Logger: logger.error(message, stack, path, method)
        ErrHandler->>Sentry: captureError(error)
        Sentry->>Sentry: beforeSend → scrub PII
        Sentry->>Sentry: Send to Sentry Cloud
        ErrHandler->>Alerts: trackError(error)
        Alerts->>Alerts: Add to sliding window
        alt Error spike (5+ in 60s)
            Alerts->>Slack: POST webhook (CRITICAL alert)
        end
        ErrHandler-->>Client: { error: "internal_error", message: "An unexpected error occurred" }, 500
    end

SLO/SLI Definitions

Service Level Indicators (SLIs)

SLI Measurement Source
Availability Percentage of successful health check responses (200) App Runner health checks, Cloudflare origin monitoring
Latency p50, p95, p99 response time for API endpoints Sentry performance monitoring, CloudWatch
Error Rate Percentage of 5xx responses out of total requests Sentry issue counts, structured logs
Database Latency dbLatencyMs from /v1/health endpoint Health check response, CloudWatch custom metric
Auth Success Rate Percentage of successful BankID authentications Sentry custom metrics, audit_log table

Service Level Objectives (SLOs)

SLO Target Measurement Window Error Budget
API Availability 99.9% 30-day rolling 43 minutes/month downtime
API Latency (p95) < 300ms 30-day rolling 5% of requests may exceed
API Latency (p99) < 1000ms 30-day rolling 1% of requests may exceed
Error Rate (5xx) < 0.1% 30-day rolling 1 in 1000 requests
Database Latency < 50ms 30-day rolling 5% of queries may exceed
Auth Success Rate > 99% 30-day rolling 1% of auth attempts may fail
Transaction Success Rate > 99.5% 30-day rolling 0.5% of transactions may fail

Performance Targets (from architecture document)

Metric Target Source
First Contentful Paint (FCP) < 1.5s architecture-document.md section 8
Largest Contentful Paint (LCP) < 2.5s architecture-document.md section 8
Time to First Byte (TTFB) < 200ms architecture-document.md section 8
API p95 response time < 300ms architecture-document.md section 8
Lighthouse score > 90 architecture-document.md section 8
Build time < 60s architecture-document.md section 8

Alert Rule Configuration

Rule Condition Severity Channel Cooldown
Error spike 5+ errors in 60s window critical Slack webhook 10 min
API startup Application start event info Slack webhook None
API shutdown Graceful shutdown event info Slack webhook None
Health check failure /v1/health returns 503 critical App Runner auto-restart + CloudWatch alarm N/A
High error rate 5xx rate > 5% for 2 minutes critical CloudWatch alarm → SNS → Slack 5 min
High latency p95 > 500ms for 3 minutes warning CloudWatch alarm → SNS → Slack 5 min
Database slow query Query > 1000ms warning Sentry performance alert 10 min
Auth failure spike 10+ auth failures in 5 minutes warning Sentry alert rule 15 min

Release Tracking

Source Map Upload

# In CI/CD pipeline after build
sentry-cli releases new "drop-api@${VERSION}"
sentry-cli releases set-commits "drop-api@${VERSION}" --auto
sentry-cli sourcemaps upload --release "drop-api@${VERSION}" ./dist/
sentry-cli releases finalize "drop-api@${VERSION}"

Release Metadata

Each release in Sentry includes:


Cross-References

Sumsub KYC Integration

Sumsub KYC/AML Integration

Identity verification architecture for the Drop fintech platform using Sumsub as the KYC/AML provider. Covers webhook architecture, applicant lifecycle, document check types, risk level definitions, SDK integration for web and mobile, idempotency handling, and error recovery.


KYC Verification Flow

sequenceDiagram
    participant User as User (Web/Mobile)
    participant Client as Drop Client
    participant API as drop-api (Hono)
    participant DB as Database
    participant Sumsub as Sumsub API
    participant SumsubSDK as Sumsub SDK Widget

    Note over User,SumsubSDK: Step 1: Initiate KYC

    User->>Client: Navigate to KYC verification
    Client->>API: POST /v1/user/kyc/initiate
    API->>API: Verify auth (authMiddleware)
    API->>DB: Check users.kyc_status
    alt Already approved
        DB-->>API: kyc_status = 'approved'
        API-->>Client: { status: "approved" }
    else Needs verification
        API->>Sumsub: POST /resources/applicants<br/>{ externalUserId, email, levelName }
        Sumsub-->>API: { id: applicantId }
        API->>Sumsub: POST /resources/accessTokens<br/>{ userId, ttlInSecs: 3600 }
        Sumsub-->>API: { token: accessToken }
        API->>DB: UPDATE users SET kyc_status = 'pending'
        API->>DB: INSERT INTO screening_results
        API-->>Client: { status: "pending", redirectUrl, externalId }
    end

    Note over User,SumsubSDK: Step 2: Document Verification (in Sumsub SDK)

    Client->>SumsubSDK: Initialize SDK with accessToken
    User->>SumsubSDK: Upload ID document (passport/ID card)
    User->>SumsubSDK: Take selfie (liveness check)
    SumsubSDK->>Sumsub: Submit documents for review
    Sumsub->>Sumsub: AI + human review

    Note over User,SumsubSDK: Step 3: Webhook Callback

    Sumsub->>API: POST /v1/webhooks/sumsub<br/>{ type, applicantId, reviewResult }
    API->>API: Verify HMAC signature
    API->>API: Check idempotency (screening_results)
    API->>DB: UPDATE users SET kyc_status = 'approved'/'rejected'
    API->>DB: INSERT INTO screening_results
    API->>DB: INSERT INTO audit_log
    API-->>Sumsub: 200 OK

Applicant State Machine

stateDiagram-v2
    [*] --> init: POST /resources/applicants
    init --> pending: Documents submitted
    pending --> queued: Automated checks passed, queued for review
    queued --> completed: Review finished
    pending --> completed: Fast-track (low risk)
    completed --> [*]

    state completed {
        [*] --> GREEN: All checks passed
        [*] --> RED: Verification failed
        [*] --> RETRY: Resubmission requested
    }

    note right of init
        User created in Sumsub
        SDK widget opened
        No documents yet
    end note

    note right of pending
        Documents uploaded
        AI processing
    end note

    note right of queued
        AI checks passed
        Awaiting human review
        (if required by level)
    end note

    note left of GREEN
        kyc_status = 'approved'
        Full access granted
    end note

    note left of RED
        kyc_status = 'rejected'
        Financial operations blocked
        rejectLabels explains why
    end note

    note left of RETRY
        kyc_status = 'pending'
        User prompted to resubmit
    end note

Status Mapping

Sumsub Status Sumsub Review Answer Drop kyc_status User Impact
init N/A pending Cannot perform financial operations
pending N/A pending Cannot perform financial operations
queued N/A pending Cannot perform financial operations
completed GREEN approved Full access to remittance and QR payments
completed RED rejected Blocked from financial operations, can appeal
completed RETRY pending Prompted to resubmit documents
onHold N/A pending Under manual review

Webhook Architecture

Webhook Endpoint

URL: POST /v1/webhooks/sumsub Authentication: HMAC-SHA256 signature verification

Webhook Events

Event Type Trigger Drop Action
applicantCreated Applicant record created in Sumsub Log to audit_log, no status change
applicantPending Documents submitted, verification in progress Update kyc_status to pending if not already
applicantReviewed Verification completed (approved or rejected) Update kyc_status based on reviewResult.reviewAnswer
applicantOnHold Manual review required Log to audit_log, status remains pending
applicantActionPending Additional action required from user Notify user via notifications table
applicantReset Application reset for resubmission Reset kyc_status to pending

Signature Verification

POST /v1/webhooks/sumsub
Headers:
  X-Payload-Digest: HMAC-SHA256(request_body, SUMSUB_SECRET_KEY)
  Content-Type: application/json

Verification logic:

  1. Read raw request body
  2. Compute HMAC-SHA256(body, SUMSUB_SECRET_KEY)
  3. Compare with X-Payload-Digest header (timing-safe comparison)
  4. Reject with 401 if signature mismatch

Retry Policy

Sumsub retries webhook delivery on non-2xx responses:

Attempt Delay Total Wait
1 Immediate 0s
2 30s 30s
3 2m 2m 30s
4 10m 12m 30s
5 30m 42m 30s
6 1h 1h 42m 30s
7 2h 3h 42m 30s
8 (final) 4h 7h 42m 30s

After 8 failed attempts, the webhook is marked as failed in Sumsub dashboard.


Idempotency Handling

Webhooks may be delivered multiple times. Drop handles this via the screening_results table:

  1. On webhook receipt, check if screening_results already has an entry with matching user_id + screening_type + same result
  2. If duplicate: return 200 OK immediately (acknowledge but don't process)
  3. If new or changed result: process the update, insert new screening_results record

Database Records Created Per Webhook

Table Record Purpose
users UPDATE kyc_status Primary KYC status
screening_results INSERT Detailed verification result with timestamp
audit_log INSERT Immutable audit trail
notifications INSERT (if status changed) User notification

Document Check Types

Verification Level: basic-kyc-level

Check Type Description Required
Document authenticity IDENTITY Verify ID document is genuine (not altered/forged) Yes
Liveness check SELFIE Verify real person (not photo/video) Yes
Face match SELFIE Match selfie to document photo Yes
Data extraction IDENTITY OCR extraction of name, DOB, document number Yes
Sanctions screening SCREENING Check against OFAC, UN, EU sanctions lists Yes
PEP screening SCREENING Politically Exposed Persons database check Yes
Adverse media SCREENING News and media screening for negative coverage Optional

Accepted Document Types

Document Type Sumsub Code Countries
Passport PASSPORT All
National ID card ID_CARD EEA countries
Driver's license DRIVERS Norway, Sweden, Denmark, Finland
Residence permit RESIDENCE_PERMIT Norway (non-citizen residents)

Risk Level Definitions

Risk Level Matrix

Risk Level Score Range Criteria Drop Action
Low 0-30 All checks passed, no PEP/sanctions match, standard country kyc_status = 'approved', standard transaction limits
Medium 31-60 Minor issues (document quality, partial name match), non-sanctioned PEP kyc_status = 'approved', enhanced monitoring via aml_alerts, lower initial limits
High 61-80 Potential PEP match, high-risk country, document concerns kyc_status = 'pending', manual review required, flag in screening_results
Critical 81-100 Sanctions match, confirmed fraud indicators, age verification failure kyc_status = 'rejected', immediate block, create aml_alerts record, file potential STR

Risk Assessment Factors

Factor Weight Source
Document authenticity score 30% Sumsub AI analysis
Liveness/face match score 20% Sumsub biometric analysis
Country risk (origin/destination) 20% Drop internal risk scoring
PEP/sanctions screening 20% Sumsub screening databases
Transaction pattern analysis 10% Drop aml_alerts historical data

High-Risk Countries for Drop

Based on remittance corridor analysis and FATF grey/black lists:

Category Countries Additional Controls
Standard Norway, Sweden, Denmark, Finland, EU/EEA Standard KYC
Enhanced due diligence Turkey, Pakistan Enhanced monitoring, lower limits
Restricted FATF grey list countries Manual review required
Blocked OFAC/UN sanctioned countries Automatic rejection

SDK Integration

Web Integration (WebSDK)

// Initialize Sumsub WebSDK in drop-web
import snsWebSdk from '@sumsub/websdk';

function launchSumsubWidget(accessToken: string, applicantId: string) {
  const snsWebSdkInstance = snsWebSdk.init(accessToken, () => {
    // Token expiry handler — request new token from API
    return fetch('/v1/user/kyc/refresh-token')
      .then(res => res.json())
      .then(data => data.token);
  })
  .withConf({
    lang: 'nb',  // Norwegian
    theme: 'dark',
    uiConf: {
      customCssStr: ':root { --primary-color: #0B6E35; }',  // Drop brand green
    },
  })
  .withOptions({
    addViewportTag: false,
    adaptIframeHeight: true,
  })
  .on('onError', (error) => {
    captureError(error, { tags: { component: 'sumsub-websdk' } });
  })
  .on('onApplicantStatusChanged', (payload) => {
    if (payload.reviewResult?.reviewAnswer === 'GREEN') {
      // Redirect to dashboard
      window.location.href = '/dashboard';
    }
  })
  .build();

  snsWebSdkInstance.launch('#sumsub-container');
}

Mobile Integration (React Native SDK)

// Initialize Sumsub React Native SDK in drop-mobile
import SNSMobileSDK from '@sumsub/react-native-mobilesdk-plugin';

async function launchSumsubMobile(accessToken: string) {
  const snsMobileSDK = SNSMobileSDK.init(accessToken, async () => {
    // Token expiry handler
    const response = await fetch(`${API_URL}/v1/user/kyc/refresh-token`, {
      headers: { Authorization: `Bearer ${authToken}` },
    });
    const data = await response.json();
    return data.token;
  })
  .withHandlers({
    onStatusChanged: (event) => {
      if (event.newStatus === 'Approved') {
        navigation.navigate('Dashboard');
      }
    },
    onError: (error) => {
      Sentry.captureException(error);
    },
  })
  .withLocale('nb')
  .withTheme({
    primaryColor: '#0B6E35',
  })
  .build();

  const result = await snsMobileSDK.launch();
  return result;
}

Error Recovery

Failure Scenarios and Recovery

Failure Detection Recovery
Sumsub API timeout (applicant creation) 30s timeout in kyc.ts:44 Return rejected with error message, user can retry
Sumsub API timeout (status check) 30s timeout in kyc.ts:148 Return rejected, poll again on next request
Access token generation failure Non-200 response from /resources/accessTokens Return pending with applicant ID (applicant created but no widget)
Webhook delivery failure Sumsub retry (8 attempts over 7h) Automatic retry by Sumsub
Webhook signature mismatch 401 response to webhook Alert ops team, check SUMSUB_SECRET_KEY rotation
Database write failure on webhook Transaction rollback Return 500 to trigger Sumsub retry
Duplicate webhook Idempotency check via screening_results Acknowledge with 200, skip processing
SDK widget crash onError callback Capture in Sentry, show user-friendly error with retry option

Manual Recovery Procedures

If a user is stuck in pending status after Sumsub has completed review:

  1. Check screening_results table for latest result
  2. Query Sumsub API: GET /resources/applicants/{externalUserId}/status
  3. If Sumsub shows GREEN but DB shows pending: manually update via admin endpoint
  4. If Sumsub shows RED: communicate rejection reason to user

Environment Variables

Variable Required Description
SUMSUB_API_URL Production Sumsub API base URL (e.g., https://api.sumsub.com)
SUMSUB_APP_TOKEN Production Application token from Sumsub dashboard
SUMSUB_SECRET_KEY Production Secret key for HMAC signature verification
SUMSUB_WEBHOOK_SECRET Production Separate secret for webhook payload verification
NEXT_PUBLIC_SERVICE_MODE All When mock: uses auto-approve mock, no Sumsub calls

Demo Mode Behavior

Source: lib/services/kyc.ts:26-28

When NEXT_PUBLIC_SERVICE_MODE=mock (demo mode):

The mock Sumsub service (mock-sumsub.ts) provides a full simulation for UI development with applicant creation, document submission, and asynchronous verification (3-second delay, 90% approval rate).


Cross-References

AISP Registration Serbia — NBS Response (Tok)

AISP Registracija u Srbiji — NBS Odgovor

Datum: 2026-03-18 Izvor: Narodna Banka Srbije (platni.sistem@nbs.rs) Kontekst: Odgovor na naš upit od 03.03.2026 u ime ALAI Holding AS (org.nr. 932 516 136) Namjena: Osnivanje ALAI Tech d.o.o. u Srbiji za AISP uslugu na srpskom tržištu


Ključni nalazi

Zakonski okvir

Postupak registracije

  1. Osnovati d.o.o. u Srbiji (ALAI Tech d.o.o.)
  2. Pripremiti dokumentaciju po članu 82 stav 1 (tačke 1, 2, 4, 5, 8, 10, 12, 15, 19, 20, 21, 23)
  3. Zaključiti ugovor o osiguranju od odgovornosti sa društvom za osiguranje
  4. Podnijeti zahtjev za registraciju Narodnoj banci Srbije

Osiguranje (OBAVEZNO)

Kapitalni zahtjev

Napomena

Email od NBS je označen kao "UNUTRAŠNJA UPOTREBA" — puni tekst (9200+ chars) trunciran u našem sistemu. Potrebno dohvatiti kompletnu verziju za sve detalje o potrebnim dokumentima.

Sljedeći koraci

  1. Dohvatiti puni tekst emaila sa svim detaljima dokumentacije
  2. Pripremiti listu potrebnih dokumenata po članu 82a
  3. Istražiti opcije osiguranja od odgovornosti u Srbiji
  4. Planirati osnivanje ALAI Tech d.o.o.