Skip to main content

API Specification

API Specification

Project: Drop API Name: Drop REST API Version: 1.0 Date: 2026-02-23 Author: Petter Graff, Senior Enterprise Architect Status: In Review Reviewers: Alem Bašić (CEO), John (AI Director) Spec Format: OpenAPI 3.1

Document History

Version Date Author Changes
0.1 2026-02-23 Petter Graff Initial draft — extracted from source code analysis

1. API Overview

Purpose: Drop REST API is the server-side backend for the Drop PSD2 pass-through payment app. It handles user authentication via BankID OIDC, remittance transaction initiation (PISP), bank balance reads (AISP), QR merchant payments, KYC/AML compliance, and GDPR data subject rights. Drop NEVER holds customer money — all payment operations initiate transfers directly from the user's bank account.

Primary Consumers:

  • drop-web — Next.js 15 web app (BFF pattern; cookie-based auth; calls via fetch from server components and client components)
  • drop-mobile — Expo SDK 54 React Native app (Bearer token auth; calls via fetch from mobile client)

Design Philosophy: REST + JSON | Resource-oriented | Stateless | Idempotent where possible | RFC 7807 error format | PSD2-compliant disclosure before payment | Dual-driver DB (SQLite dev / PostgreSQL prod)

Response Envelope (all responses):

// Success
{ "data": { ... } }

// Error
{ "error": "error_code", "message": "Human-readable message", "details": [...] }

Base URLs:

Environment Base URL
Production https://api.getdrop.no/api/v1 (TBD — awaiting domain config)
Staging TBD — requires staging environment setup
Development http://localhost:3000/api

2. API Versioning Strategy

Strategy: URL path versioning — /api/v{MAJOR}

Current version: v1 (implicit — routes are at /api/... without explicit version prefix in current codebase)

Versioning rules:

  • MAJOR version (v1 → v2): Breaking changes — new base path, deprecation notice ≥ 6 months
  • MINOR additions: Non-breaking — new optional fields, new endpoints — no version bump
  • Patch: Bug fixes — no schema changes

Deprecation Policy:

  • Deprecated endpoints marked with Deprecation and Sunset headers
  • Minimum 6 months notice before removing deprecated endpoints
  • Deprecation notices sent to: engineering Slack channel + API changelog

Sunset Header Example:

Deprecation: Sat, 01 Jan 2026 00:00:00 GMT
Sunset: Sat, 01 Jul 2026 00:00:00 GMT
Link: <https://api.getdrop.no/v2/transactions>; rel="successor-version"

3. Authentication & Authorization

3.1 Authentication Methods

Drop supports two auth mechanisms depending on the client:

Web (Next.js BFF) — httpOnly Cookie:

Cookie: drop_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
  • Set by server on BankID callback; never accessible to JavaScript
  • Cookie flags: httpOnly, sameSite=lax, secure (production)

Mobile (Expo) — Bearer Token:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
  • Stored in AsyncStorage on device
  • Sent as Authorization: Bearer {token} header

JWT Claims (HS256, signed with JWT_SECRET):

{
  "sub": "usr_a1b2c3d4e5f6a7b8",
  "email": "[email protected]",
  "role": "user",
  "iat": 1700000000,
  "exp": 1700086400
}

Token Lifetimes:

Token Type Lifetime Storage
Web JWT (cookie) 24 hours httpOnly cookie
Mobile JWT 7 days AsyncStorage (encrypted)
BankID OIDC id_token Short-lived (BankID-managed) Not stored — extracted claims only

Refresh Flow:

POST /api/auth/refresh
Cookie: drop_token={CURRENT_JWT}
→ 200: { "data": { "userId": "usr_...", "email": "...", "role": "user" } }
→ 401: Token expired — re-authenticate via BankID

3.2 BankID OIDC Authentication (Primary)

BankID is the sole authentication method (email/password returns 410 Gone — permanently removed).

1. GET /api/auth/bankid → redirects to BankID authorization endpoint
2. User completes BankID (web: browser redirect; mobile: expo-web-browser deep link)
3. BankID redirects to /api/auth/bankid/callback?code=AUTH_CODE
4. Server exchanges code for id_token, validates pid (fødselsnummer), creates session
5. JWT issued; web: set as httpOnly cookie; mobile: returned in response body

3.3 Authorization Rules

Role Description Granted When
user Standard registered user Default on BankID registration
merchant Can accept QR payments After successful POST /api/merchants/register

KYC Gate: Endpoints marked KYC Required enforce kyc_status = 'approved' — returns 403 if pending or rejected.


4. Common Headers

Request Headers

Header Required Description
Authorization Yes (mobile endpoints) Bearer {JWT}
Cookie Yes (web endpoints) drop_token={JWT} (set automatically by browser)
Content-Type Yes (POST/PATCH) application/json
Accept No application/json (default)
X-Request-ID Recommended UUID v4 — echoed in response for tracing
X-Idempotency-Key Recommended (POST mutations) UUID v4 — prevents duplicate payment operations
Accept-Language No nb (Norwegian Bokmål, default), en, bs, sq

Response Headers

Header Description
X-Request-ID Echo of request ID (or generated UUID if not provided)
X-RateLimit-Limit Rate limit ceiling for this endpoint
X-RateLimit-Remaining Remaining requests in current window
X-RateLimit-Reset Unix timestamp when rate limit resets
Content-Type application/json

5. Error Response Format

Drop uses a simplified error envelope (not full RFC 7807 in current implementation, but aligned):

{
  "error": "error_code",
  "message": "Human-readable error description (Norwegian or English)",
  "details": [
    {
      "field": "amount",
      "code": "out_of_range",
      "message": "Amount must be between 100 and 50000 NOK"
    }
  ]
}

Standard Error Codes

HTTP Status Error Code When Used
400 bad_request Missing/invalid JSON body or parameters
400 no_bank_account No linked bank account found
400 validation_error Field validation failures (see details array)
401 unauthorized Missing or expired authentication token
402 insufficient_balance Bank account balance too low for transaction
403 forbidden Authenticated but lacks permission
403 kyc_required KYC not approved; transaction blocked
404 not_found Resource not found or not owned by user
409 conflict Duplicate resource (e.g., email already registered)
410 gone Permanently removed endpoint (e.g., email/password auth)
422 validation_error Business rule violation (unsupported currency corridor, etc.)
429 rate_limited Too many requests
500 internal_error Unexpected server error (logged to Sentry)
503 service_unavailable Database unreachable (health check only)

6. Pagination Strategy

Strategy: Offset-based pagination for all list endpoints.

Request:

GET /api/transactions?page=1&limit=20&type=remittance&status=completed

Response:

{
  "data": [ ... ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "total": 47
  }
}

Limits: Minimum 1, Maximum 50 items per request (100 for complaints endpoint).


7. Rate Limiting

Rate limiting is enforced via the rate_limits database table (key = {endpoint}:{ip_address}). Expired entries are pruned on each request.

Endpoint Group Limit Window Scope
POST /api/auth/register 10 req per minute Per IP
POST /api/auth/login 10 req per minute Per IP
POST /api/transactions/remittance 10 req per minute Per IP
POST /api/transactions/qr-payment 10 req per minute Per IP
GET /api/rates 120 req per minute Per IP
GET /api/rates/[currency] 120 req per minute Per IP
All other authenticated endpoints No explicit limit (DB-backed)

Rate limit exceeded response:

HTTP/1.1 429 Too Many Requests

{
  "error": "rate_limited",
  "message": "For mange forespørsler. Prøv igjen om litt."
}

8. Endpoint Documentation

Resource: Authentication

POST /api/auth/register

Summary: Create a new Drop user account (email + password flow — DEPRECATED; BankID is primary) Auth: None Rate Limit: 10 req/min per IP

Request:

POST /api/auth/register HTTP/1.1
Content-Type: application/json

{
  "email": "[email protected]",
  "password": "SecurePass123",
  "firstName": "Amir",
  "lastName": "Hodžić",
  "phone": "+4740474251",
  "dateOfBirth": "1995-03-15"
}

Validation:

Field Type Required Rules
email string Yes RFC email format; unique
password string Yes Min 8 chars; must contain letters + digits
firstName string Yes 1–100 chars; at least one letter; no HTML/script
lastName string Yes Same as firstName
phone string No +XXXXXXXXXXXX international format (8–15 digits)
dateOfBirth string Yes ISO date; age >= 18 years

Response 201 Created:

{
  "data": {
    "id": "usr_a1b2c3d4e5f67890",
    "email": "[email protected]",
    "firstName": "Amir",
    "lastName": "Hodžić",
    "dateOfBirth": "1995-03-15",
    "kycStatus": "pending",
    "createdAt": "2026-02-23T10:00:00.000Z"
  }
}

Error scenarios:

Scenario Status Error Code
Missing required field 400 bad_request
Invalid JSON body 400 bad_request
Email already registered 409 conflict
Age < 18 422 validation_error
Invalid name (HTML/script) 422 validation_error

POST /api/auth/login

Summary: Authenticate with email and password (legacy — BankID preferred) Auth: None Rate Limit: 10 req/min per IP

Request:

POST /api/auth/login HTTP/1.1
Content-Type: application/json

{
  "email": "[email protected]",
  "password": "SecurePass123"
}

Response 200 OK:

{
  "data": {
    "id": "usr_a1b2c3d4e5f67890",
    "email": "[email protected]",
    "firstName": "Amir",
    "lastName": "Hodžić",
    "kycStatus": "approved"
  }
}

Sets drop_token httpOnly cookie on success.

Error scenarios:

Scenario Status Error Code
Missing email or password 400 bad_request
Invalid credentials 401 unauthorized
Too many attempts 429 rate_limited

GET /api/auth/me

Summary: Get the current authenticated user with linked bank accounts Auth: Required (cookie or Bearer)

Response 200 OK:

{
  "data": {
    "id": "usr_a1b2c3d4e5f67890",
    "email": "[email protected]",
    "firstName": "Amir",
    "lastName": "Hodžić",
    "totalBalance": 58030.0,
    "bankAccounts": [
      {
        "id": "ba_1",
        "bankName": "DNB",
        "accountNumber": "1234.56.78901",
        "balance": 45230.0,
        "currency": "NOK",
        "isPrimary": true
      },
      {
        "id": "ba_2",
        "bankName": "SpareBank 1",
        "accountNumber": "9876.54.32100",
        "balance": 12800.0,
        "currency": "NOK",
        "isPrimary": false
      }
    ],
    "kycStatus": "approved",
    "createdAt": "2026-01-15T08:30:00.000Z"
  }
}

Note: balance values are AISP-read cached balances from real bank accounts — NOT Drop-held funds.


POST /api/auth/logout

Summary: Logout and revoke all active sessions Auth: Required (cookie or Bearer)

Calls revokeAllSessions() — invalidates all sessions records for the user. Clears the drop_token httpOnly cookie.

Response 200 OK:

{ "message": "Logged out" }

POST /api/auth/refresh

Summary: Issue a new JWT and create a new session record Auth: Required (existing cookie or Bearer)

Response 200 OK:

{
  "data": {
    "userId": "usr_a1b2c3d4e5f67890",
    "email": "[email protected]",
    "role": "user"
  }
}

Resource: Transactions

GET /api/transactions

Summary: List user's transactions with pagination and filtering Auth: Required

Query Parameters:

Parameter Type Default Description
page integer 1 Page number (min 1)
limit integer 20 Items per page (min 1, max 50)
type string Filter: remittance or qr_payment
status string Filter: processing, completed, or failed

Response 200 OK:

{
  "data": [
    {
      "id": "tx_rem_a1b2c3d4e5f67890",
      "type": "remittance",
      "status": "completed",
      "amount": -2000,
      "currency": "NOK",
      "recipientName": "Mama Jasmina",
      "createdAt": "2026-02-17T14:30:00.000Z"
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "total": 3
  }
}

Note: amount is negated in the response (always shown as outgoing from user's perspective).


GET /api/transactions/[id]

Summary: Get single transaction details with exchange rate info Auth: Required

Path Parameters:

Parameter Type Description
id string Transaction ID (format: tx_<hex16>)

Response 200 OK:

{
  "data": {
    "id": "tx_rem_a1b2c3d4e5f67890",
    "type": "remittance",
    "status": "completed",
    "sendAmount": 2000,
    "sendCurrency": "NOK",
    "receiveAmount": 23400,
    "receiveCurrency": "RSD",
    "exchangeRate": 11.7,
    "fee": 10,
    "total": 2010,
    "recipientName": "Mama Jasmina",
    "recipientCountry": "Serbia",
    "createdAt": "2026-02-17T14:30:00.000Z",
    "completedAt": "2026-02-17T14:31:02.000Z"
  }
}

Error scenarios:

Scenario Status Error Code
Transaction not found 404 not_found
Transaction belongs to other user 404 not_found

GET /api/transactions/summary

Summary: Get all-time and current-month transaction statistics Auth: Required

Response 200 OK:

{
  "data": {
    "allTime": {
      "totalCount": 3,
      "totalSent": 5000,
      "totalPaid": 129,
      "remittanceCount": 2,
      "qrPaymentCount": 1
    },
    "thisMonth": {
      "totalCount": 1,
      "totalSent": 2000,
      "totalPaid": 0,
      "remittanceCount": 1,
      "qrPaymentCount": 0
    }
  }
}

POST /api/transactions/disclosure

Summary: Get full fee and exchange rate disclosure before initiating a payment (PSD2 RTS Art. 45 requirement) Auth: Required

Request:

POST /api/transactions/disclosure HTTP/1.1
Authorization: Bearer {TOKEN}
Content-Type: application/json

{
  "type": "remittance",
  "amount": 2000,
  "currency": "NOK",
  "recipientId": "rec_a1b2c3d4e5f67890"
}
Field Type Required Notes
type string Yes remittance or qr_payment
amount number Yes Must be positive
currency string No Defaults to NOK
recipientId string Conditional Required for remittance

Response 200 OK:

{
  "amount": 2000,
  "fee": 10,
  "feePercentage": 0.5,
  "exchangeRate": 11.7,
  "receiveAmount": 23400,
  "receiveCurrency": "RSD",
  "estimatedDelivery": "1-2 business days",
  "totalCost": 2010
}

Fee Calculation:

  • Remittance: fee = amount × 0.005 (0.5%)
  • QR payment: fee = amount × 0.01 (1.0%)

Estimated Delivery:

  • QR payment: "Instant"
  • Remittance EEA (EU/SEPA): "1-2 business days"
  • Remittance non-EEA: "2-4 business days"

POST /api/transactions/remittance

Summary: Initiate an international remittance (PISP — payment from user's bank account) Auth: Required KYC: Required (kyc_status = 'approved') Rate Limit: 10 req/min per IP

Request:

POST /api/transactions/remittance HTTP/1.1
Authorization: Bearer {TOKEN}
Content-Type: application/json
X-Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000

{
  "recipientId": "rec_a1b2c3d4e5f67890",
  "amount": 2000,
  "currency": "NOK",
  "bankAccountId": "ba_1"
}
Field Type Required Validation
recipientId string Yes Must belong to authenticated user
amount number Yes 100–50,000 NOK; max 2 decimal places
currency string No Defaults to NOK
bankAccountId string No Defaults to user's primary bank account

Business Logic (executed atomically):

  1. Verify recipient_id belongs to user
  2. Look up exchange rate for recipient's currency
  3. Verify bank account exists and balance >= (amount + fee)
  4. Fee = amount × 0.005
  5. Debit bank_accounts.balance by (amount + fee) (cached AISP balance update)
  6. Insert transactions record with status = 'processing'

Response 201 Created:

{
  "data": {
    "id": "tx_rem_a1b2c3d4e5f67890",
    "type": "remittance",
    "status": "processing",
    "sendAmount": 2000,
    "sendCurrency": "NOK",
    "receiveAmount": 23400,
    "receiveCurrency": "RSD",
    "exchangeRate": 11.7,
    "fee": 10,
    "feePercent": 0.5,
    "total": 2010,
    "recipientName": "Mama Jasmina",
    "recipientCountry": "Serbia",
    "fromAccount": "DNB",
    "eta": "1-2 business days",
    "createdAt": "2026-02-23T10:00:00.000Z"
  }
}

Error scenarios:

Scenario Status Error Code
Missing/invalid fields 400 bad_request
No linked bank account 400 no_bank_account
Balance too low 402 insufficient_balance
KYC not approved 403 kyc_required
Recipient not found 404 not_found
Unsupported currency corridor 422 validation_error

POST /api/transactions/qr-payment

Summary: Initiate a QR payment to a merchant (PISP — payment from user's bank account) Auth: Required Rate Limit: 10 req/min per IP

Request:

POST /api/transactions/qr-payment HTTP/1.1
Authorization: Bearer {TOKEN}
Content-Type: application/json

{
  "merchantId": "mer_a1b2c3d4e5f67890",
  "amount": 129
}
Field Type Required Validation
merchantId string Yes Must be an active merchant
amount number Yes 1–100,000 NOK; max 2 decimal places

Business Logic (executed atomically):

  1. Verify merchant exists and is active
  2. Get user's primary bank account
  3. Fee = amount × merchant.fee_rate (default 1%)
  4. Debit bank_accounts.balance by (amount + fee)
  5. Insert transactions record with status = 'completed' (instant)

Response 201 Created:

{
  "data": {
    "id": "tx_qr_a1b2c3d4e5f67890",
    "type": "qr_payment",
    "status": "completed",
    "amount": 129,
    "currency": "NOK",
    "fee": 1.29,
    "feePercent": 1,
    "merchantName": "Ahmetov Kebab",
    "merchantId": "mer_a1b2c3d4e5f67890",
    "fromAccount": "DNB",
    "createdAt": "2026-02-23T10:00:00.000Z"
  }
}

GET /api/transactions/[id]/receipt

Summary: Get a transaction receipt with full disclosure details Auth: Required

Response 200 OK:

{
  "data": {
    "transactionId": "tx_rem_a1b2c3d4e5f67890",
    "date": "2026-02-17T14:30:00.000Z",
    "type": "remittance",
    "amount": 2000,
    "currency": "NOK",
    "fee": 10,
    "exchangeRate": 11.7,
    "receiveAmount": 23400,
    "receiveCurrency": "RSD",
    "recipient": {
      "name": "Mama Jasmina",
      "country": "RS"
    },
    "reference": "tx_rem_a1b2c3d4e5f67890",
    "status": "completed",
    "estimatedCompletion": null,
    "completedAt": "2026-02-17T14:31:02.000Z"
  }
}

Resource: Recipients

GET /api/recipients

Summary: List user's saved remittance recipients Auth: Required

Query Parameters: page (default 1), limit (default 20, max 50)

Bank account numbers are masked in response (e.g., *****5678).

Supported destination countries: RS (Serbia), BA (Bosnia & Herzegovina), PL (Poland), PK (Pakistan), TR (Turkey)

Response 200 OK:

{
  "data": [
    {
      "id": "rec_a1b2c3d4e5f67890",
      "name": "Mama Jasmina",
      "country": "RS",
      "currency": "RSD",
      "bankAccount": "*****5678",
      "bankName": "Raiffeisen Serbia",
      "createdAt": "2026-01-20T08:00:00.000Z"
    }
  ],
  "pagination": { "page": 1, "limit": 20, "total": 3 }
}

POST /api/recipients

Summary: Add a new remittance recipient Auth: Required

Request:

POST /api/recipients HTTP/1.1
Authorization: Bearer {TOKEN}
Content-Type: application/json

{
  "name": "Mama Jasmina",
  "country": "RS",
  "currency": "RSD",
  "bankAccount": "1234567890123456",
  "bankName": "Raiffeisen Serbia"
}
Field Type Required Validation
name string Yes validateName() — 1–100 chars, no HTML/script
country string Yes Must be in: RS, BA, PL, PK, TR
currency string Yes Must match country's supported currency
bankAccount string Yes Stored as-is; masked on read
bankName string No Sanitized to max 200 chars

Response 201 Created:

{
  "data": {
    "id": "rec_a1b2c3d4e5f67890",
    "name": "Mama Jasmina",
    "country": "RS",
    "currency": "RSD",
    "bankAccount": "*****5678",
    "bankName": "Raiffeisen Serbia",
    "createdAt": "2026-02-23T10:00:00.000Z"
  }
}

DELETE /api/recipients/[id]

Summary: Delete a saved recipient Auth: Required

Response 204 No Content on success.

Error scenarios:

Scenario Status Error Code
Recipient not found 404 not_found
Recipient belongs to other user 404 not_found

Resource: Exchange Rates

GET /api/rates

Summary: Get all exchange rates from NOK (public endpoint) Auth: None Rate Limit: 120 req/min per IP

Response 200 OK:

{
  "data": {
    "baseCurrency": "NOK",
    "rates": {
      "RSD": 11.7,
      "BAM": 1.04,
      "PLN": 0.41,
      "PKR": 26.8,
      "TRY": 3.45,
      "EUR": 0.089
    },
    "updatedAt": "2026-02-23T08:00:00.000Z"
  }
}

GET /api/rates/[currency]

Summary: Get exchange rate for a specific currency pair (NOK → target) Auth: None Rate Limit: 120 req/min per IP

Path Parameters:

Parameter Type Description
currency string Target currency code (e.g., RSD, EUR)

Response 200 OK:

{
  "data": {
    "from": "NOK",
    "to": "RSD",
    "rate": 11.7,
    "fee": 0.005,
    "updatedAt": "2026-02-23T08:00:00.000Z"
  }
}

Resource: Notifications

GET /api/notifications

Summary: List all notifications for the authenticated user Auth: Required Feature Flag: notifications (default: enabled)

Response 200 OK:

{
  "data": [
    {
      "id": "noti_a1b2c3d4e5f67890",
      "type": "transaction_completed",
      "title": "Betaling fullført",
      "body": "Din overføring på 2000 kr til Mama Jasmina er fullført.",
      "read": false,
      "createdAt": "2026-02-23T10:01:00.000Z"
    }
  ]
}

PATCH /api/notifications

Summary: Mark one or more notifications as read Auth: Required Feature Flag: notifications

Request:

{ "notificationIds": ["noti_a1b2c3d4e5f67890", "noti_b2c3d4e5f6789012"] }
  • Max 100 IDs per request
  • IDs validated against format ^[a-z]+_[a-f0-9]{16}$

Response 200 OK:

{ "data": { "updated": 2 } }

Resource: Settings

GET /api/settings

Summary: Get user preferences (creates defaults if none exist) Auth: Required

Defaults: currency=NOK, language=nb, pushEnabled=true, emailEnabled=true

Response 200 OK:

{
  "data": {
    "currency": "NOK",
    "language": "nb",
    "pushEnabled": true,
    "emailEnabled": true,
    "updatedAt": "2026-02-23T09:00:00.000Z"
  }
}

PATCH /api/settings

Summary: Update user preferences (all fields optional) Auth: Required

Request:

{
  "currency": "EUR",
  "language": "en",
  "pushEnabled": false,
  "emailEnabled": true
}
Field Type Allowed Values
currency string EUR, USD, GBP, BAM, CHF, PLN, NOK, RSD, TRY, PKR
language string nb, en, bs, sq
pushEnabled boolean true, false
emailEnabled boolean true, false

Resource: Merchants

POST /api/merchants/register

Summary: Register as a merchant (enables QR payment acceptance) Auth: Required

Request:

POST /api/merchants/register HTTP/1.1
Authorization: Bearer {TOKEN}
Content-Type: application/json

{
  "businessName": "Ahmetov Kebab",
  "orgNumber": "123456789",
  "address": "Storgata 1, 0182 Oslo",
  "bankAccount": "12345678901"
}
Field Type Required Validation
businessName string Yes validateName()
orgNumber string Yes Exactly 9 digits; unique across merchants
address string No Sanitized to max 300 chars
bankAccount string Yes Merchant payout account

Upgrades user role to merchant. Returns a QR code URI (drop://pay/{merchantId}).

Response 201 Created:

{
  "data": {
    "id": "mer_a1b2c3d4e5f67890",
    "businessName": "Ahmetov Kebab",
    "orgNumber": "123456789",
    "qrCode": "drop://pay/mer_a1b2c3d4e5f67890",
    "status": "active"
  }
}

GET /api/merchants/dashboard

Summary: Get merchant revenue statistics Auth: Required (merchant role)

Query Parameters:

Parameter Type Values
period string today (default), week, month

Response 200 OK:

{
  "data": {
    "period": "today",
    "revenue": 2580.0,
    "transactionCount": 20,
    "fees": 25.8,
    "netRevenue": 2554.2,
    "nextPayout": "2026-02-24",
    "payoutTime": "09:00"
  }
}

GET /api/merchants/qr

Summary: Get merchant QR code data Auth: Required (merchant role)

Response 200 OK:

{
  "data": {
    "merchantId": "mer_a1b2c3d4e5f67890",
    "businessName": "Ahmetov Kebab",
    "qrValue": "drop://pay/mer_a1b2c3d4e5f67890",
    "address": "Storgata 1, 0182 Oslo"
  }
}

GET /api/merchants/transactions

Summary: List merchant's QR payment transactions Auth: Required (merchant role)

Query Parameters: page (default 1), limit (default 20)

Customer names are partially anonymized (First L.) in response.


Resource: GDPR & Compliance

GET /api/user/data-export

Summary: Export all user data (GDPR Art. 20 — Right to data portability) Auth: Required

Creates a data_access_requests record (type=export, status=completed).

Response 200 OK:

{
  "data": {
    "user": {
      "id": "usr_a1b2c3d4e5f67890",
      "email": "[email protected]",
      "first_name": "Amir",
      "last_name": "Hodžić",
      "phone": "+4740474251",
      "date_of_birth": "1995-03-15",
      "kyc_status": "approved",
      "role": "user",
      "created_at": "2026-01-15T08:30:00.000Z"
    },
    "transactions": [ "..." ],
    "recipients": [ "..." ],
    "bankAccounts": [ "..." ],
    "settings": { "currency": "NOK", "language": "nb" },
    "consents": [ "..." ]
  },
  "exportedAt": "2026-02-23T10:00:00.000Z"
}

DELETE /api/user/account

Summary: Request account deletion (GDPR Art. 17 — Right to erasure) Auth: Required

Behavior:

  • Soft-deletes user (sets deleted_at timestamp on users row)
  • Revokes all active sessions
  • Creates data_access_requests record (type=erasure, status=completed)
  • Data is retained for 5 years per AML/KYC legal requirements (hvitvaskingsloven §30)

Response 200 OK:

{
  "message": "Account scheduled for deletion",
  "retentionNote": "Data retained for 5 years per AML requirements"
}

GET /api/consents

Summary: List user's GDPR consent records Auth: Required

Response 200 OK:

{
  "data": [
    {
      "id": "con_a1b2c3d4e5f67890",
      "user_id": "usr_a1b2c3d4e5f67890",
      "consent_type": "terms",
      "granted": 1,
      "granted_at": "2026-01-15T08:30:00.000Z",
      "withdrawn_at": null,
      "ip_address": "192.0.2.1"
    }
  ]
}

POST /api/consents

Summary: Grant or withdraw a consent Auth: Required

Request:

{
  "consentType": "marketing",
  "granted": true
}
Field Type Required Allowed Values
consentType string Yes terms, privacy, marketing, cookies_analytics, cookies_marketing
granted boolean Yes true = grant, false = withdraw

Response 200 OK (update) / 201 Created (new):

{
  "data": {
    "id": "con_a1b2c3d4e5f67890",
    "consent_type": "marketing",
    "granted": 1,
    "granted_at": "2026-02-23T10:00:00.000Z",
    "withdrawn_at": null,
    "ip_address": "192.0.2.1"
  }
}

GET /api/complaints

Summary: List user's submitted complaints Auth: Required

Query Parameters: page (default 1), limit (default 10, max 100)

Response 200 OK:

{
  "data": [
    {
      "id": "cmp_a1b2c3d4e5f67890",
      "category": "transaction",
      "subject": "Transaction delayed",
      "description": "My remittance to Serbia is delayed by 3 days.",
      "status": "received",
      "resolution": null,
      "created_at": "2026-02-20T09:00:00.000Z",
      "resolved_at": null
    }
  ],
  "pagination": { "page": 1, "limit": 10, "total": 1, "totalPages": 1 }
}

POST /api/complaints

Summary: Submit a formal complaint (Finansavtaleloven §3-53 — 15 business day response requirement) Auth: Required

Request:

{
  "category": "fees",
  "subject": "High transfer fee",
  "description": "The 0.5% fee for sending to Serbia seems high compared to competitors."
}
Field Type Required Validation
category string Yes transaction, service, fees, privacy, technical, other
subject string Yes Max 200 chars; sanitized
description string Yes Max 2000 chars; sanitized

Response 201 Created:

{
  "data": {
    "id": "cmp_a1b2c3d4e5f67890",
    "category": "fees",
    "subject": "High transfer fee",
    "description": "The 0.5% fee for sending to Serbia seems high compared to competitors.",
    "status": "received",
    "created_at": "2026-02-23T10:00:00.000Z"
  },
  "commitmentNote": "We will review and respond to your complaint within 15 business days per Finansavtaleloven §3-53"
}

Resource: Health Check

GET /api/health

Summary: System health check (no auth required — used by App Runner health probe) Auth: None

Response 200 OK:

{
  "status": "ok",
  "version": "0.1.0",
  "uptime": 3600,
  "db": "connected",
  "dbLatencyMs": 1,
  "timestamp": "2026-02-23T10:00:00.000Z"
}

Response 503 Service Unavailable (database unreachable):

{
  "status": "error",
  "db": "disconnected",
  "timestamp": "2026-02-23T10:00:00.000Z"
}

9. Webhook Documentation

9.1 Incoming Webhooks (Drop receives)

Drop receives webhooks from external providers. It does not currently send outbound webhooks to third parties.

Sumsub KYC Webhook

Endpoint: POST /api/webhooks/sumsub (TBD — endpoint path pending implementation) Verification: HMAC-SHA256 with SUMSUB_SECRET_KEY

// Sumsub webhook signature verification
const payload = request.rawBody;
const receivedDigest = request.headers['x-payload-digest'];
const secret = process.env.SUMSUB_SECRET_KEY;

const expectedDigest = crypto
  .createHmac('sha256', secret)
  .update(payload)
  .digest('hex');

const isValid = crypto.timingSafeEqual(
  Buffer.from(receivedDigest),
  Buffer.from(expectedDigest)
);

Events received:

Event Description Action
applicantReviewed KYC review completed Update users.kyc_status; insert screening_results
applicantPending Verification in progress Update users.kyc_status = 'pending'

Sumsub retry policy: 8 attempts over 7h 42m (1m → 2m → 4m → 8m → 15m → 30m → 1h → 2h)

Neonomics Open Banking Webhook (Phase 2 — planned)

Purpose: Async PISP payment status updates Events: Payment completed, payment failed, payment reversed Implementation: TBD — requires Neonomics contract (ADR-013)


10. OpenAPI 3.1 YAML Skeleton

openapi: '3.1.0'

info:
  title: 'Drop REST API'
  description: 'PSD2 pass-through payment app API — remittance and QR merchant payments for Norwegian residents. Drop never holds customer funds; all payments are PISP-initiated from user bank accounts.'
  version: '1.0.0'
  contact:
    name: 'Drop Engineering'
    email: '[email protected]'
  license:
    name: 'Proprietary'

servers:
  - url: 'https://api.getdrop.no/api'
    description: 'Production'
  - url: 'http://localhost:3000/api'
    description: 'Development'

security:
  - cookieAuth: []
  - bearerAuth: []

tags:
  - name: 'Auth'
    description: 'Authentication via BankID OIDC'
  - name: 'Transactions'
    description: 'Remittance and QR payment operations'
  - name: 'Recipients'
    description: 'Saved remittance recipients'
  - name: 'Rates'
    description: 'Exchange rate lookup (public)'
  - name: 'Merchants'
    description: 'Merchant registration and dashboard'
  - name: 'Notifications'
    description: 'In-app notification management'
  - name: 'Settings'
    description: 'User preferences'
  - name: 'GDPR'
    description: 'Compliance — data export, erasure, consents, complaints'
  - name: 'Health'
    description: 'System health (public)'

paths:
  /auth/me:
    get:
      tags: ['Auth']
      summary: 'Get current user with bank accounts'
      operationId: 'getMe'
      responses:
        '200':
          description: 'Authenticated user with AISP-read bank balances'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MeResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /transactions:
    get:
      tags: ['Transactions']
      summary: 'List transactions'
      operationId: 'listTransactions'
      parameters:
        - $ref: '#/components/parameters/page'
        - $ref: '#/components/parameters/limit'
        - name: type
          in: query
          schema:
            type: string
            enum: ['remittance', 'qr_payment']
        - name: status
          in: query
          schema:
            type: string
            enum: ['processing', 'completed', 'failed']
      responses:
        '200':
          description: 'OK'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaginatedTransactionResponse'

  /transactions/remittance:
    post:
      tags: ['Transactions']
      summary: 'Initiate remittance (PISP)'
      operationId: 'createRemittance'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateRemittanceRequest'
      responses:
        '201':
          description: 'Remittance created with status processing'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RemittanceResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '402':
          $ref: '#/components/responses/InsufficientBalance'
        '403':
          $ref: '#/components/responses/KycRequired'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/ValidationError'

  /rates:
    get:
      tags: ['Rates']
      summary: 'Get all NOK exchange rates'
      operationId: 'getAllRates'
      security: []
      responses:
        '200':
          description: 'Exchange rates from NOK'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RatesResponse'

  /health:
    get:
      tags: ['Health']
      summary: 'System health check'
      operationId: 'healthCheck'
      security: []
      responses:
        '200':
          description: 'System healthy'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthResponse'
        '503':
          description: 'Database unreachable'

components:
  securitySchemes:
    cookieAuth:
      type: apiKey
      in: cookie
      name: drop_token
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

  parameters:
    page:
      name: page
      in: query
      schema:
        type: integer
        minimum: 1
        default: 1
    limit:
      name: limit
      in: query
      schema:
        type: integer
        minimum: 1
        maximum: 50
        default: 20

  schemas:
    Transaction:
      type: object
      required: [id, type, status, amount, currency, createdAt]
      properties:
        id:
          type: string
          pattern: '^tx_[a-f0-9]{16}$'
          example: 'tx_rem_a1b2c3d4e5f67890'
        type:
          type: string
          enum: ['remittance', 'qr_payment']
        status:
          type: string
          enum: ['processing', 'completed', 'failed']
        amount:
          type: number
          description: 'Negated outgoing amount in NOK'
        currency:
          type: string
          default: 'NOK'
        createdAt:
          type: string
          format: date-time

    CreateRemittanceRequest:
      type: object
      required: [recipientId, amount]
      properties:
        recipientId:
          type: string
          pattern: '^rec_[a-f0-9]{16}$'
        amount:
          type: number
          minimum: 100
          maximum: 50000
        currency:
          type: string
          default: 'NOK'
        bankAccountId:
          type: string

    RemittanceResponse:
      type: object
      properties:
        data:
          type: object
          properties:
            id:
              type: string
            type:
              type: string
              enum: ['remittance']
            status:
              type: string
              enum: ['processing']
            sendAmount:
              type: number
            sendCurrency:
              type: string
            receiveAmount:
              type: number
            receiveCurrency:
              type: string
            exchangeRate:
              type: number
            fee:
              type: number
            feePercent:
              type: number
            total:
              type: number
            recipientName:
              type: string
            recipientCountry:
              type: string
            fromAccount:
              type: string
            eta:
              type: string
            createdAt:
              type: string
              format: date-time

    RatesResponse:
      type: object
      properties:
        data:
          type: object
          properties:
            baseCurrency:
              type: string
              example: 'NOK'
            rates:
              type: object
              additionalProperties:
                type: number
              example:
                RSD: 11.7
                BAM: 1.04
                PLN: 0.41
                PKR: 26.8
                TRY: 3.45
                EUR: 0.089
            updatedAt:
              type: string
              format: date-time

    HealthResponse:
      type: object
      properties:
        status:
          type: string
          enum: ['ok', 'error']
        version:
          type: string
        uptime:
          type: integer
        db:
          type: string
          enum: ['connected', 'disconnected']
        dbLatencyMs:
          type: integer
        timestamp:
          type: string
          format: date-time

    MeResponse:
      type: object
      properties:
        data:
          type: object
          properties:
            id:
              type: string
            email:
              type: string
            firstName:
              type: string
            lastName:
              type: string
            totalBalance:
              type: number
              description: 'Sum of all AISP-read bank account balances — NOT Drop-held funds'
            bankAccounts:
              type: array
              items:
                $ref: '#/components/schemas/BankAccount'
            kycStatus:
              type: string
              enum: ['pending', 'approved', 'rejected']
            createdAt:
              type: string
              format: date-time

    BankAccount:
      type: object
      properties:
        id:
          type: string
        bankName:
          type: string
        accountNumber:
          type: string
        balance:
          type: number
        currency:
          type: string
        isPrimary:
          type: boolean

    PaginatedTransactionResponse:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Transaction'
        pagination:
          type: object
          properties:
            page:
              type: integer
            limit:
              type: integer
            total:
              type: integer

    ErrorResponse:
      type: object
      properties:
        error:
          type: string
        message:
          type: string
        details:
          type: array
          items:
            type: object
            properties:
              field:
                type: string
              code:
                type: string
              message:
                type: string

  responses:
    BadRequest:
      description: 'Bad Request'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    Unauthorized:
      description: 'Unauthorized — missing or expired token'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    InsufficientBalance:
      description: 'Payment Required — bank account balance too low'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    KycRequired:
      description: 'Forbidden — KYC not approved'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    NotFound:
      description: 'Not Found'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    ValidationError:
      description: 'Unprocessable Entity — business rule violation'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

11. SDK Generation Notes

Current status: No SDK generated — Drop API is consumed internally by drop-web (Next.js 15) and drop-mobile (Expo SDK 54) only. No public API access.

If SDK generation is introduced (Phase 3+):

Language Package Registry
TypeScript/JS @alai/drop-client npm (private)

Generation command:

openapi-generator-cli generate \
  -i ./api-specification.yaml \
  -g typescript-fetch \
  -o ./sdk/typescript \
  --additional-properties=npmName=@alai/drop-client

12. API Changelog

Version Date Change Type Description
1.0.0 2026-02-23 Initial All endpoints documented from source code analysis
1.0.0 2026-02-16 Added GDPR compliance endpoints (/api/consents, /api/complaints, /api/user/data-export, /api/user/account)
1.0.0 2026-02-16 Added POST /api/transactions/disclosure — PSD2 pre-payment fee disclosure
1.0.0 2026-01-01 Removed POST /api/auth/login email/password auth deprecated in favour of BankID (returns 410)

Approval

Role Name Date Signature
Author Petter Graff 2026-02-23
API Consumer Rep
Security Review
Tech Lead John (AI Director)