Integration Specifications
Third-party integration architecture and flows
- BankID OIDC Integration
- Open Banking (AISP/PISP)
- Payment Processing
- Sentry Observability
- Sumsub KYC Integration
- AISP Registration Serbia — NBS Response (Tok)
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:
- Extracts JWT from cookie (web) or Authorization header (mobile)
- Verifies JWT signature and expiry with
jose.jwtVerify() - Hashes the JWT with SHA-256
- Looks up the session by
token_hash - Verifies
revoked = 0andexpires_at > now - 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:
- Parse birthdate from pid digits → ISO date string (
YYYY-MM-DD) - Verify age >= 18 by comparing birthdate to current date (
isOver18()) - Hash pid with SHA-256 for storage:
crypto.createHash("sha256").update(pid).digest("hex") - 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:
- The same person logging in on different devices gets the same account
- Re-authentication after token expiry reconnects to the existing account
- Future Vipps Login integration can match users by the same pid hash
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:
- Drop authentication (login): BankID OIDC — satisfies SCA for account access
- 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:
initiateOIDC()generates a redirect URL (same structure, but not called)- Frontend skips BankID redirect, posts a mock code to callback
exchangeAndVerify()detectsisMock=trueand returns mock user infofindOrCreateUser()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:
- Register Vipps Login client at
portal.vipps.no - Add Vipps OIDC endpoints alongside BankID
- Vipps also returns Norwegian pid (fødselsnummer)
- User deduplication via
national_id_hash— same hash regardless of whether user logs in with BankID or Vipps - 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
- Authentication System: ../../backend/AUTHENTICATION.md — Full auth system documentation
- Open Banking AISP/PISP: open-banking-aisp-pisp.md — ASPSP SCA (separate from BankID login SCA)
- Security Architecture: ../hld/security-architecture.md — JWT security, session management
- Login Flow (LLD): ../lld/flow-login-authentication.md — Step-by-step login UX
- Database Schema: ../../backend/DATABASE-SCHEMA.md —
users,sessionstables - Source:
src/drop-api/src/lib/bankid.ts— BankID OIDC implementation
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:
- AISP (Account Information Service Provider) — reads bank account balances and transaction history from the user's bank
- PISP (Payment Initiation Service Provider) — initiates payments directly from the user's bank account
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:
- Register with Finanstilsynet (Norwegian FSA) as a payment institution or authorized agent
- Obtain eIDAS certificates (QWAC for TLS, QSeal for signing) from a qualified TSP
- Register in the EBA TPP Register for EU-wide passporting
- 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
3.1 Consent Flow
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
After consent is granted, Drop reads balances to display on the Dashboard. The bank_accounts.balance column stores the cached AISP-read value — it is never a Drop-held balance.
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:
GET /api/auth/mereturnstotalBalanceandbankAccounts[].balance— these are cached AISP reads from thebank_accountstablebank_accounts.balance_synced_attracks when the balance was last refreshed from the ASPSP
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:
- The payer must be made aware of the amount and payee during authentication
- The authentication code must be specific to that amount and payee
- Any change to amount or payee invalidates the authentication
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:
- Generate
idempotency_keyfrom:{userId}:{recipientId}:{amount}:{timestamp_minute} - Include as
X-Request-IDheader in PISP API calls - If ASPSP returns a duplicate error, look up existing transaction by
idempotency_key - Return the existing transaction to the user (no double-charge)
5. Consent Lifecycle
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 --> [*]
5.2 Consent Properties
| 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 |
5.3 Consent Storage
Drop tracks consents in two places:
consentstable — GDPR consent records (consent_type:psd2_aisp,psd2_pisp)bank_accountstable — Links to ASPSP consent ID for each linked account
When a consent expires or is revoked:
bank_accounts.balanceis zeroed (stale data removed)bank_accounts.balance_synced_atis nulled- User is prompted to re-link via notification
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:
- Drop sends AISP consent or PISP payment request to ASPSP
- ASPSP returns an
scaRedirectURL - Drop redirects the user to the bank's SCA page
- User authenticates with BankID (knowledge + possession factors)
- 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:
- Simplifies implementation
- Reduces fraud risk
- Avoids complex exemption logic (low value, trusted beneficiary, recurring)
- Aligns with Norwegian banks' SCA enforcement
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:
- Single API integration (vs. per-bank integration)
- Aggregator handles eIDAS certificates, bank onboarding, fallback
- Faster time-to-market
- Aggregator maintains bank API compatibility as banks update
Trade-offs:
- Additional per-transaction cost
- Data passes through third party (GDPR implications)
- Dependency on aggregator uptime
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
- BankID OIDC Integration: bankid-oidc-integration.md — Drop's authentication layer (separate from ASPSP SCA)
- Payment Processing: payment-processing.md — SEPA/SWIFT settlement, FX, fees
- Security Architecture: ../hld/security-architecture.md — Threat model, SCA controls
- Bank Account Linking Flow: ../lld/flow-bank-account-linking.md — Detailed AISP consent UX
- Remittance Flow: ../lld/flow-remittance.md — Detailed PISP payment UX
- Database Schema: ../../backend/DATABASE-SCHEMA.md —
bank_accounts,transactions,consentstables - API Reference: ../../backend/API-REFERENCE.md — Drop API endpoints
- Compliance Status: ../../security/COMPLIANCE.md — PSD2 readiness assessment
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:
- Remittance — cross-border money transfers to 30+ countries (via SEPA SCT/SCTInst for EEA, SWIFT gpi for non-EEA)
- 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:
- User sees rate on the disclosure screen
- Rate is locked when user confirms (before SCA)
- If SCA completes within 15 minutes, the locked rate applies
- 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:
- Before creating a transaction, check if
idempotency_keyalready exists - If exists, return the existing transaction (no duplicate)
- If not, insert new transaction with the key
- Pass the same key as
X-Request-IDto 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:
WHERE balance >= ?prevents overdraw- PostgreSQL MVCC +
READ COMMITTEDisolation (default) prevents dirty reads; useSERIALIZABLEfor phantom read protection - Fee calculated and included in the single atomic debit
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
- Open Banking AISP/PISP: open-banking-aisp-pisp.md — Berlin Group API integration, consent lifecycle
- BankID OIDC: bankid-oidc-integration.md — Authentication (not payment SCA)
- Security Architecture: ../hld/security-architecture.md — Fraud detection, AML screening
- Remittance Flow (LLD): ../lld/flow-remittance.md — Step-by-step remittance UX
- Database Schema: ../../backend/DATABASE-SCHEMA.md —
transactions,exchange_rates,bank_accountstables - API Reference: ../../backend/API-REFERENCE.md — Transaction, disclosure, and rate endpoints
- Compliance: ../../security/COMPLIANCE.md — PSD2, AML readiness
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:
- Keys containing:
password,pin,cardNumber,cvv,fødselsnummer,authorization,cookie - Values replaced with
[REDACTED]
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:
- Set as a Hono context variable (
app.ts:34) - Returned in the
x-request-idresponse header (app.ts:36) - Included in all log entries for the request
- 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:
- Version:
drop-api@{package.json version}(e.g.,drop-api@0.1.0) - Environment:
development,staging, orproduction - Commits: Automatically linked via
--autoflag - Source maps: Uploaded for stack trace deobfuscation
Cross-References
- Error handler:
src/drop-api/src/middleware/error-handler.ts— Global error handler that triggers Sentry capture - Logger:
src/drop-api/src/lib/logger.ts— Structured JSON logging - Sentry SDK:
src/drop-api/src/lib/sentry.ts— Full Sentry integration with data scrubbing - Alert system:
src/drop-api/src/lib/alerts.ts— Slack webhook alerting with spike detection - Security architecture: SECURITY-ARCHITECTURE.md — Security monitoring context
- Deployment: deployment-architecture.md — CloudWatch and infrastructure monitoring
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:
- Read raw request body
- Compute
HMAC-SHA256(body, SUMSUB_SECRET_KEY) - Compare with
X-Payload-Digestheader (timing-safe comparison) - 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:
- On webhook receipt, check if
screening_resultsalready has an entry with matchinguser_id+screening_type+ sameresult - If duplicate: return 200 OK immediately (acknowledge but don't process)
- If new or changed result: process the update, insert new
screening_resultsrecord
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:
- Check
screening_resultstable for latest result - Query Sumsub API:
GET /resources/applicants/{externalUserId}/status - If Sumsub shows
GREENbut DB showspending: manually update via admin endpoint - 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):
initiateKyc()returns{ status: "approved" }immediatelycheckKycStatus()returns{ status: "approved" }immediately- No Sumsub API calls are made
- Users created via BankID get
kyc_status = 'approved'automatically (bankid.ts:233)
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
- KYC service:
src/drop-app/src/lib/services/kyc.ts— Production KYC initiation and status check - Mock Sumsub:
src/drop-app/src/lib/services/mock-sumsub.ts— Demo mode mock implementation - BankID integration: AUTHENTICATION.md — BankID auto-approves KYC
- Database schema: DATABASE-SCHEMA.md —
screening_results,aml_alerts,users.kyc_status - Compliance: COMPLIANCE.md — AML/KYC regulatory requirements
- Security: SECURITY-ARCHITECTURE.md — Role-based access, KYC status enforcement
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
- Član 82a Zakona o platnim uslugama (RS br. 139/2014, 44/2018, 64/2024) — posebna pravila za firme koje pružaju ISKLJUČIVO AISP uslugu
- Ako firma pruža i druge platne usluge, primjenjuje se član 82 (stroži zahtjevi)
Postupak registracije
- Osnovati d.o.o. u Srbiji (ALAI Tech d.o.o.)
- Pripremiti dokumentaciju po članu 82 stav 1 (tačke 1, 2, 4, 5, 8, 10, 12, 15, 19, 20, 21, 23)
- Zaključiti ugovor o osiguranju od odgovornosti sa društvom za osiguranje
- Podnijeti zahtjev za registraciju Narodnoj banci Srbije
Osiguranje (OBAVEZNO)
- Polisa mora pokrivati teritorije na kojima se pruža usluga
- Pokriva odgovornost za: neovlašćen pristup informacijama o platnom računu, pristup sa ciljem prevare, neovlašćeno korišćenje informacija
- Alternativa: drugo odgovarajuće sredstvo za pokriće odgovornosti
Kapitalni zahtjev
- Za čisti AISP (samo informacijske usluge): nema minimalnog kapitalnog zahtjeva (za razliku od PISP)
- Ovo je značajna prednost za ulazak na tržište
Zakonski link
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
- Dohvatiti puni tekst emaila sa svim detaljima dokumentacije
- Pripremiti listu potrebnih dokumenata po članu 82a
- Istražiti opcije osiguranja od odgovornosti u Srbiji
- Planirati osnivanje ALAI Tech d.o.o.