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 viafetchfrom server components and client components)drop-mobile— Expo SDK 54 React Native app (Bearer token auth; calls viafetchfrom 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
DeprecationandSunsetheaders - 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
AsyncStorageon 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):
- Verify
recipient_idbelongs to user - Look up exchange rate for recipient's currency
- Verify bank account exists and balance >= (amount + fee)
- Fee =
amount × 0.005 - Debit
bank_accounts.balanceby(amount + fee)(cached AISP balance update) - Insert
transactionsrecord withstatus = '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):
- Verify merchant exists and is
active - Get user's primary bank account
- Fee =
amount × merchant.fee_rate(default 1%) - Debit
bank_accounts.balanceby(amount + fee) - Insert
transactionsrecord withstatus = '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_attimestamp onusersrow) - Revokes all active sessions
- Creates
data_access_requestsrecord (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) |