Integration Design
Integration Design Document
Project:
{{PROJECT_NAME}}Drop Integration:{{INTEGRATION_NAME}}BankID OIDC + Open Banking (Neonomics AISP/PISP) + Sumsub KYC/AML Version:{{VERSION}}1.0 Date:{{DATE}}2026-02-23 Author:{{AUTHOR}}Petter Graff, Senior Enterprise Architect Status:Draft | In Review |Approved Reviewers:{{REVIEWERS}}Alem Bašić (CEO), John (AI Director)
Document History
| Version | Date | Author | Changes |
|---|---|---|---|
| 0.1 | Initial draft from real integration docs |
1. Integration Overview & Context
Integration Name: {{INTEGRATION_NAME}}Drop External Integration Stack (BankID OIDC + Neonomics Open Banking + Sumsub KYC)
Type: Synchronous (REST/gRPC)HTTPS) |+ Asynchronous (Events/Queue) | Bidirectional | File-basedWebhooks)
Business Purpose: {{WHY_THIS_INTEGRATION_EXISTS}}
Criticality:cannot Criticalfunction |without Highthese |three Medium | Lowintegrations:
Impact if down:BankID:{{BUSINESS_IMPACT_IF_UNAVAILABLE}}Every user must authenticate with Norwegian Strong Customer Authentication — no other login method existsAcceptableOpendowntime:Banking (Neonomics/ASPSP):{{RTO}}All|balance reads (AISP) and payment initiations (PISP) require live bank API connectivity — Drop never holds funds- Sumsub KYC: Norwegian AML law (hvitvaskingsloven) requires identity verification before any financial operation
Criticality:
- BankID:
MaxCritical — all logins blocked if down; RTO 1 hour - Open Banking PISP: Critical — all payments blocked if down; RTO 2 hours; RPO 0 (no payment data
loss:lost — payments either completed at bank or not initiated) - Open Banking AISP: High
{{RPO}}— balance display degrades to cached value; acceptable for 24 hours - Sumsub: High — new user KYC blocked; existing approved users unaffected; RTO 4 hours
Parties:
| Party | System | Team | Contact |
|---|---|---|---|
| Consumer ( |
|||
| Provider: Open Banking | Neonomics REST API | Neonomics | neonomics.io |
| Provider: KYC | Sumsub REST API + Webhooks | Sumsub | sumsub.com |
2. Integration Topology Diagram
flowchart LRTB
subgraph ConsumerSide[UserSide["ConsumerUser —Devices"]
{{CONSUMER_SYSTEM}}Browser["Browser (Next.js)"]
C_SVC[{{ConsumerService}}]App["Mobile C_CB[Circuit Breaker]
C_RETRY[Retry Handler]
end
subgraph Integration[(Expo)"Integration Layer"]
GW[API Gateway / Load Balancer]
Q[Message Queue\n{{QUEUE_NAME}}]
DLQ[Dead Letter Queue\n{{DLQ_NAME}}]
end
subgraph ProviderSide[DropPlatform["ProviderDrop —Platform {{PROVIDER_SYSTEM}}(AWS eu-north-1)"]
P_SVC[{{ProviderService}}subgraph Edge["Cloudflare Edge"]
P_DB[(ProviderCF["WAF DB)+ CDN + DDoS"]
P_WORKER[Event Worker]
end
C_SVCsubgraph App_["drop-web (Next.js BFF)"]
WebBFF["Next.js API Routes\n/api/auth/bankid/*"]
end
subgraph API["drop-api (Hono v4)"]
AuthRoute["routes/auth.ts\nBankID OIDC callback"]
TxRoute["routes/transactions.ts\nPISP orchestration"]
KYCRoute["routes/user.ts\nKYC initiation + webhooks"]
WebhookRoute["POST /v1/webhooks/sumsub\nHMAC-verified"]
end
subgraph DB["PostgreSQL 16"]
Users["users + sessions"]
Txs["transactions"]
Kyc["screening_results\nkyc_status"]
end
end
subgraph BankIDProvider["BankID Norge (auth.bankid.no)"]
BIDAuth["OIDC /auth endpoint"]
BIDToken["OIDC /token endpoint"]
BIDJWKS["JWKS /certs endpoint"]
end
subgraph Neonomics["Neonomics Open Banking"]
NeoAISP["AISP: GET /v1/accounts/{id}/balances"]
NeoPISP["PISP: POST /v1/payments/sepa-credit-transfers"]
NeoCB["Circuit Breaker\n3 failures → 60s cooldown"]
end
subgraph SumsubProvider["Sumsub KYC"]
SumAPI["REST API\n/resources/applicants"]
SumWebhook["Webhooks (HMAC-signed)\nPOST → Drop /v1/webhooks/sumsub"]
SumSDK["WebSDK (web) +\nReact Native SDK (mobile)"]
end
Browser & App --> C_CBCF
C_CBCF --> C_RETRYWebBFF C_RETRY& -->|HTTPSAPI
REST| GW
GWWebBFF --> P_SVCBIDAuth
P_SVCBIDToken --> P_DBAuthRoute
P_WORKERBIDJWKS -->|Publish| QAuthRoute
QAuthRoute -->|Consume| C_SVCUsers
QTxRoute -->|Failed| DLQ
DLQNeoCB -->|Alert| AlertSystem[PagerDuty]NeoPISP & NeoAISP
KYCRoute --> SumAPI
SumWebhook --> WebhookRoute
WebhookRoute --> Kyc
SumSDK --> Browser & App
3. Service Contracts
3.1 Integration: {{INTEGRATION_NAME_1}}BankID OIDC (Authentication)
Protocol: REST/OpenID Connect 1.0 Authorization Code Flow over HTTPS | gRPC | GraphQL | WebSocket | AMQP
Direction: {{CONSUMER}}Drop → {{PROVIDER}}BankID Norge
Idempotency: YES — usestate and Idempotency-Keynonceheaderparameters | NOper-request
Authentication
| Method | Details |
|---|---|
| Type | |
| Client ID Header | Sent in token exchange POST body: |
| Client Secret | Sent in token exchange POST body: client_secret=BANKID_CLIENT_SECRET |
| Key rotation | |
| Token endpoint | |
Request Contract — Step 1: Initiate (Redirect)
Endpoint: {{HTTP_METHOD}}GET {{BASE_URL}}https:/{{PATH}}/auth.bankid.no/auth/realms/prod/protocol/openid-connect/auth
Headers:Query Parameters:
Authorization:client_id=BANKID_CLIENT_ID
Bearerredirect_uri=https://getdrop.no/api/auth/bankid/callback
{{JWT_OR_API_KEY}}response_type=code
Content-Type:scope=openid+profile
application/jsonstate=CRYPTO_RANDOM_UUID
Accept: application/json
X-Request-ID: {{UUID}}
X-Idempotency-Key: {{IDEMPOTENCY_KEY}}nonce=CRYPTO_RANDOM_UUID
Drop stores state in: httpOnly cookie bankid_state={state} (web) or in-memory (mobile)
Request Contract — Step 2: Token Exchange
Endpoint: POST https://auth.bankid.no/auth/realms/prod/protocol/openid-connect/token
Request Body:
{grant_type=authorization_code
"{{field1}}":code=AUTH_CODE
"{{type}}redirect_uri=https://getdrop.no/api/auth/bankid/callback
—client_id=BANKID_CLIENT_ID
{{description}}",
"{{field2}}": "{{type}} — {{description}}",
"metadata": {
"sourceSystem": "{{CONSUMER_SYSTEM_ID}}",
"timestamp": "ISO8601"
}
}client_secret=BANKID_CLIENT_SECRET
Successful Response 200: / 201
{
"{{responseField1}}"id_token": "{{type}}eyJ...",
"{{responseField2}}"access_token": "{{type}}...",
"requestId"token_type": "echoBearer",
of"expires_in": X-Request-ID"300
}
ID Token Claims Used by Drop
| Claim | Type | Usage |
|---|---|---|
pid |
string | Norwegian fødselsnummer (11 digits). Hashed (SHA-256) → users.national_id_hash |
name |
string | Full name. Split into users.first_name + users.last_name |
sub |
string | Fallback subject identifier if pid absent |
iss |
string | Validated: https://auth.bankid.no/auth/realms/prod |
aud |
string | Validated: matches BANKID_CLIENT_ID |
exp |
number | Token expiry — verified via jose |
nonce |
string | Anti-replay — matched against stored nonce |
Error Handling
| HTTP Status | Error Code | |
|---|---|---|
| User cancels BankID | N/A (redirect with error=access_denied) |
Return bankid_cancelled 400 |
400 |
|
token_exchange_failed |
401 |
|
|
| JWKS verification fails | N/A | Return jwks_verification_failed 502 — alert ops |
age check fails (< 18) |
N/A |
underage |
| | |
| | |
| | |
| | |
| | |
| |
Retry Policy
MaxToken retries:exchange: {{MAX_RETRIES}}No retry (state-dependent flow — retry onlymeans onrestarting 500,from 502,login)
503,JWKS 429,fetch: networkjose errors)
Strategy: Exponential backoffhandles with jitter
Delays: [{{DELAY_1}}ms, {{DELAY_2}}ms, {{DELAY_3}}ms]
Timeout per attempt: {{TIMEOUT_MS}}ms
Circuit Breaker Configuration
Failure threshold: {{FAILURE_PERCENT}}% failures built-in {{WINDOW_SECONDS}}scaching window(5-minute OpenTTL)
duration:On {{OPEN_DURATION_SECONDS}}sJWKS Half-open test: 1 requestfailure: Alert on:Sentry, Circuitreturn open502 forto > {{ALERT_THRESHOLD_SECONDS}}suser
Rate Limiting
| Limit | Value | Window | Action when exceeded |
|---|---|---|---|
| 60s |
HTTP |
||
| HTTP 429 |
3.2 Integration: Open Banking AISP (Balance Reads)
Protocol: Berlin Group NextGenPSD2 v1.3.12+ over HTTPS
Direction: Drop → Neonomics → ASPSP (user's bank)
Idempotency: YES — X-Request-ID UUID per request
Authentication
| Method | Details |
|---|---|
| Type | OAuth2 Client Credentials (Neonomics) + eIDAS QWAC certificate (ASPSP direct) |
Authorization: Bearer { |
|
| Key rotation | |
| Token endpoint | https://api.neonomics.io/auth/token |
Request Contract — AISP Balance Read
Endpoint: GET https://api.neonomics.io/v1/accounts/{accountId}/balances
Headers:
Authorization: Bearer {NEONOMICS_ACCESS_TOKEN}
X-Request-ID: {UUID}
X-Consent-ID: {consentId}
Content-Type: application/json
Successful Response 200:
{
"balances": [
{
"balanceType": "expected",
"balanceAmount": {
"currency": "NOK",
"amount": "45230.00"
},
"lastChangeDateTime": "2026-02-23T08:00:00.000Z"
}
]
}
PSD2 Constraint: Maximum 4 TPP-initiated balance reads per account per day (RTS Art. 36(6)). User-initiated reads are unlimited.
Drop caches in: bank_accounts.balance (in øre) + bank_accounts.balance_synced_at
Error Handling
| HTTP Status | Berlin Group Code | Drop Handling |
|---|---|---|
403 |
CONSENT_EXPIRED |
Delete consent, prompt user to re-link bank account |
429 |
ACCESS_EXCEEDED |
Back off, show cached balance with timestamp |
500+ |
Server error | Circuit breaker, show cached balance |
Circuit Breaker Configuration
Failure threshold: 3 failures in 60s window
Open duration: 60s
Half-open test: 1 request
Alert on: Circuit open for > 5 minutes → Sentry HIGH alert
Fallback: Return cached balance from bank_accounts.balance with staleness warning
3.3 Integration: Open Banking PISP (Payment Initiation)
Protocol: Berlin Group NextGenPSD2 v1.3.12+ over HTTPS
Direction: Drop → Neonomics → ASPSP (user's bank)
Idempotency: YES — X-Request-ID = idempotency_key from transactions table
Request Contract — PISP Remittance
Endpoint: POST https://api.neonomics.io/v1/payments/sepa-credit-transfers
Headers:
Authorization: Bearer {NEONOMICS_ACCESS_TOKEN}
Content-Type: application/json
X-Request-ID: {idempotency_key}
X-Consent-ID: {pisConsentId}
Request Body:
{
"debtorAccount": {
"iban": "NO9386011117947"
},
"instructedAmount": {
"currency": "NOK",
"amount": "2010.00"
},
"creditorName": "Marko Petrovic",
"creditorAccount": {
"bban": "265-1234567-89"
},
"remittanceInformationUnstructured": "Drop remittance tx_rem_abc123"
}
Successful Response 201:
{
"paymentId": "pay_xyz123",
"transactionStatus": "RCVD",
"_links": {
"scaRedirect": {
"href": "https://dnb.no/sca/pay/abc123"
}
}
}
SCA Redirect Flow: Drop returns scaRedirect URL to client → client redirects user to bank → user authenticates with BankID at bank → bank redirects back to Drop callback → Drop polls payment status.
Retry Policy
Max retries: 3 (retry only on 500, 502, 503 — NOT on 4xx)
Strategy: Exponential backoff with jitter
Delays: [1000ms, 2000ms, 4000ms]
Timeout per attempt: 30000ms
Idempotency: X-Request-ID = idempotency_key prevents double-payment on retry
Timeout Configuration
| Timeout Type | Value | Notes |
|---|---|---|
| Connection timeout | ||
| Read timeout | ||
failed if no callback received |
3.24 Integration: {{INTEGRATION_NAME_2}}Sumsub (if applicable)KYC/AML
Protocol: gRPCREST HTTPS (outbound) + Webhooks HTTPS (inbound)
ServiceDirection: definition:Drop → Sumsub (applicant creation), Sumsub → Drop (webhook status updates)
Idempotency: YES — webhook idempotency via screening_results table check
Authentication
| Method | Details |
|---|---|
| Outbound (Drop → Sumsub) | Authorization: Bearer {SUMSUB_APP_TOKEN} |
| Inbound Webhook Verification | HMAC-SHA256 of request body using SUMSUB_SECRET_KEY |
| Webhook Header | X-Payload-Digest: HMAC-SHA256(body, SUMSUB_SECRET_KEY) |
| Key rotation | Manual rotation in Sumsub dashboard; update SUMSUB_SECRET_KEY in Secrets Manager |
Request Contract — KYC Initiation
Endpoint: POST https://api.sumsub.com/resources/applicants
Headers:
serviceAuthorization: Bearer {{ServiceName}}SUMSUB_APP_TOKEN}
Content-Type: application/json
X-Request-ID: {UUID}
rpc
Request ({{RequestMessage}})Body:
{
string"externalUserId": id"usr_abc123def456gh78",
="email": 1;"[email protected]",
string"levelName": tenant_id = 2;
{{FieldType}} {{field_name}} = 3;
}
message {{ResponseMessage}} {
string id = 1;
{{FieldType}} {{field_name}} = 2;
google.protobuf.Timestamp created_at = 3;"basic-kyc-level"
}
Successful Response 201:
{
"id": "5f9e1b2c3d4e5f6g7h8i9j0k",
"externalUserId": "usr_abc123def456gh78",
"review": {
"reviewStatus": "init"
}
}
Webhook Contract (Sumsub → Drop)
Endpoint: POST /v1/webhooks/sumsub
Verification: HMAC-SHA256(rawBody, SUMSUB_SECRET_KEY) must match X-Payload-Digest header
Webhook Payload:
{
"type": "applicantReviewed",
"applicantId": "5f9e1b2c3d4e5f6g7h8i9j0k",
"externalUserId": "usr_abc123def456gh78",
"reviewResult": {
"reviewAnswer": "GREEN",
"rejectLabels": []
},
"levelName": "basic-kyc-level",
"createdAt": "2026-02-23T10:00:00.000Z"
}
Drop Action per Event:
| Event Type | Drop kyc_status Change |
Side Effects |
|---|---|---|
applicantReviewed + GREEN |
pending → approved |
notification: "Du er nå verifisert", audit_log |
applicantReviewed + RED |
pending → rejected |
notification: "Verifisering avslått", audit_log, AML check |
applicantReviewed + RETRY |
stays pending |
notification: "Vennligst prøv igjen", audit_log |
applicantPending |
stays pending |
audit_log only |
applicantOnHold |
stays pending |
audit_log only |
Idempotency Strategy for Webhooks
For each webhook delivery:
1. Check screening_results WHERE user_id = ? AND screening_type = 'kyc' AND result = new_result
2. If found (duplicate): return 200 OK, skip processing
3. If not found: process, INSERT INTO screening_results, UPDATE users.kyc_status
4. Return 200 OK to prevent Sumsub retry
Sumsub Retry Policy (on non-2xx response):
- 8 retry attempts over 7h 42m (immediate → 30s → 2m → 10m → 30m → 1h → 2h → 4h)
Rate Limiting
| Limit | Value | Notes |
|---|---|---|
| Applicant creation | 100 req/min | Sumsub API limit |
| Webhook endpoint | No rate limit | Must always return 200 quickly |
4. Event-Driven Integrations
4.1 EventSumsub SchemasWebhook (CloudEvents 1.0)Events
Event: {{entity}}.{{ACTION}}
Published by: Sumsub
Consumed by: {{PUBLISHER_SYSTEM}}{{CONSUMER_SYSTEM_1}},Drop {{CONSUMER_SYSTEM_2}}POST /v1/webhooks/sumsub
Event: applicantReviewed
{
"specversion": "1.0",
"type": "{{REVERSE_DNS_EVENT_TYPE}}"applicantReviewed",
"source"applicantId": "https://{{SYSTEM_DOMAIN}}/{{resource}}"sumsub_internal_id",
"id"externalUserId": "{{UUID}}"usr_abc123def456gh78",
"time"inspectionId": "2024-01-01T00:00:00Z"inspection_id",
"datacontenttype"correlationId": "application/json"correlation_id",
"subject"levelName": "{{RESOURCE_ID}}"basic-kyc-level",
"data"sandboxMode": false,
"reviewStatus": "completed",
"createdAt": "2026-02-23T10:00:00.000Z",
"reviewResult": {
"entityId"reviewAnswer": "UUID of affected resource"GREEN",
"tenantId"rejectLabels": "UUID of tenant"[],
"actorId": "UUID of user who triggered event",
"{{DOMAIN_FIELD_1}}": "domain-specific data",
"{{DOMAIN_FIELD_2}}": "domain-specific data",
"previousState"reviewRejectType": null,
"newState"moderationComment": "{{STATE}}"null
}
}
4.2 Topics / Queues
| is ||||
| synchronous
4.3 Ordering Guarantees
| Integration | Ordering | Notes | |
|---|---|---|---|
| cookie |||
| Best-effort | |||
4.4 Idempotency Strategy
ForBankID eachcallback:
consumedState event:cookie 1.+ nonce = per-session idempotency; JWT issuance is idempotent (same pid = same user)
Open Banking PISP:
X-Request-ID = transactions.idempotency_key
Unique DB index ensures duplicate INSERT is rejected → return existing transaction
Sumsub webhooks:
Check processed_events table: SELECT 1 WHERE event_id = $1 AND consumer_group = $2
2. If found: log "Duplicate event skipped" and ACK (do not reprocess)
3. If not found: process event
4. On success: INSERT INTO processed_events (event_id, consumer_group, processed_at)
5. ACK message
Deduplication window: {{DEDUP_WINDOW}} (keep processed_eventsscreening_results for thisexisting duration)result before processing
Duplicate: acknowledge (200 OK), skip processing
5. Data Consistency Patterns
5.1 Consistency Model
Model: Strong |(within Drop DB) + Eventual |(between CausalDrop and external systems)
Acceptable lag: {{MAX_LAG_SECONDS}}sPISP status lag: 5 minutes max; AISP balance lag: 6 hours (PSD2 constraint)
5.2 PISP Payment Saga Pattern (if used for distributed transactions)
sequenceDiagram
autonumber
participant ODrop as OrchestratorDrop API
participant S1DB as {{SERVICE_1}}PostgreSQL
participant S2PISP as {{SERVICE_2}}Neonomics PISP
participant S3ASPSP as {{SERVICE_3}}User's O-Bank
participant User as User
Drop->>S1:DB: Execute Step 1
S1-->>O: Step 1 succeeded {result1}
O->>S2: Execute Step 2 (with result1)
S2-->>O: Step 2 succeeded {result2}
O->>S3: Execute Step 3 (with result2)
S3-->>O: Step 3 FAILED
Note over O: CompensatingINSERT transactions (reversestatus='processing', order)idempotency_key)
O-Drop->>S2:DB: CompensateCOMMIT
StepDrop->>PISP: 2POST S2-/v1/payments/sepa-credit-transfers (X-Request-ID=idempotency_key)
PISP-->>O:Drop: Compensated{paymentId, O->>S1:scaRedirect}
Compensate Step 1
S1-Drop-->>O:User: Compensated201 O-+ scaRedirect URL
User->>ASPSP: Complete BankID SCA at bank
ASPSP-->>Client:User: TransactionRedirect rolledto backDrop callback
User->>Drop: GET /api/payments/callback?paymentId=xxx
Drop->>PISP: GET /v1/payments/{paymentId}/status
PISP-->>Drop: {transactionStatus: "ACCP"}
Drop->>DB: UPDATE transactions SET status='completed'
Compensation strategies:
| Step | Compensation | Notes |
|---|---|---|
processing; PISP idempotency key prevents double charge on retry |
||
| SCA timeout (no callback in 5min) | UPDATE transactions SET status='failed'; restore cached balance | User notified to retry |
6. Integration Testing Strategy
6.1 Contract Testing (Pact)
Consumer-drivenBankID:contracts:Consumer writesIntegration testsdefiningagainstexpectedBankIDprovidertestbehaviorenvironment (BANKID_MOCK=false, BankID preprod)ProviderNeonomics:verification:IntegrationProvidertestsCIagainstrunsNeonomicsconsumer contracts on every buildsandboxPactSumsub:Broker:Mock SDK () for unit tests; staging Sumsub account for integration{{PACT_BROKER_URL}}NEXT_PUBLIC_SERVICE_MODE=mock
6.2 Integration Test Environments
| Environment | Purpose | Trigger |
|---|---|---|
| Local (BANKID_MOCK=true) | Dev testing with |
Manual |
| Staging | Full integration with BankID test + Neonomics sandbox + Sumsub staging |
Every PR merge |
| Production | Synthetic monitoring via GET /v1/health | Every 5 minutes |
6.3 Test Scenarios
Happy path:
-
{{SCENARIO_1}}BankID—loginexpected→outcomeJWT issued → dashboard loads -
{{SCENARIO_2}}Sumsub—KYCexpectedinitiatedoutcome→ webhook GREEN → kyc_status=approved - AISP balance read → cached in bank_accounts (Phase 2)
- PISP remittance → SCA redirect → payment completed (Phase 2)
Error scenarios:
-
ProviderBankID auth cancelled → 400bankid_cancelled - User under 18 → 403
underage - Sumsub webhook with invalid HMAC → 401 (rejected)
- Duplicate Sumsub webhook → 200 (idempotent skip)
- Neonomics API returns 500
—→ circuit breaker opensafter→threshold502 (Phase 2) -
ProviderPISPtimesSCAouttimeout—→retrytransactionpolicymarkedkicksfailedin(Phase Auth token expired — token refresh flow worksRate limit exceeded — 429 handled, backoff appliedDuplicate event consumed — idempotency key prevents double-processing2)
7. Monitoring & Alerting
7.1 Key Metrics
| Metric | Type | Alert Condition | Severity |
|---|---|---|---|
|
Counter | ||
| > |
HIGH | |
|
> |
MEDIUM | |
|
Counter | > 5/min | CRITICAL |
pisp_circuit_open |
Gauge | == 1 | CRITICAL |
|
> 0 | HIGH | |
|
Gauge | > |
MEDIUM |
kyc_approval_rate |
Gauge | < 70% over 1h | HIGH |
7.2 Distributed Tracing
- Trace ID propagation:
X-Request-IDx-request-idandheadergeneratedtraceparentheadersperforwardedrequest (UUID), propagated to all external calls - Sampling rate:
{{SAMPLE_RATE}}% in production,100% instagingstaging, 10% in production (Sentry performance monitoring) - Tracing tool:
{{TRACING_TOOL}}Sentry Performance —dashboard:transactions{{DASHBOARD_URL}}tracked per endpoint
7.3 Alert Routing
| Condition | Alert Channel | Escalation |
|---|---|---|
| Sentry HIGH alert | On-call engineer via Sentry | |
| PISP circuit breaker open | On-call |
|
Approval
| Role | Name | Date | Signature |
|---|---|---|---|
| Author | Petter Graff | 2026-02-23 | |
| Consumer Team Lead | John (AI Director) | ||
| Provider Team Lead | External (BankID/Neonomics/Sumsub) | ||
| Platform/Infra | |||
| Approver (CEO) | Alem Bašić |