API Specification
API Specification
Project:
{{PROJECT_NAME}}Drop API Name:{{API_NAME}}Drop REST API Version:{{API_VERSION}}1.0 Date:{{DATE}}2026-02-23 Author:{{AUTHOR}}Petter Graff, Senior Enterprise Architect Status:Draft |In Review| ApprovedReviewers:{{REVIEWERS}}Alem Bašić (CEO), John (AI Director) Spec Format: OpenAPI 3.1
Document History
| Version | Date | Author | Changes |
|---|---|---|---|
| 0.1 | Initial draft | ||
1. API Overview
Purpose: {{API_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 (e.g.,BFFinternalpattern;frontend,cookie-basedpartnerauth;systems,callspublicviadevelopers)fetchfrom 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. (TBD — awaiting domain config) |
| Staging | TBD — requires staging environment setup |
| Development | http://localhost: |
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
{{DEPRECATION_PERIOD}}6 months notice before removing deprecated endpoints - Deprecation notices sent to:
{{NOTIFICATION_CHANNEL}}engineering Slack channel + API changelog
Sunset Header Example:
Deprecation: Sat, 01 Jan 20252026 00:00:00 GMT
Sunset: Sat, 01 Jul 20252026 00:00:00 GMT
Link: <https://api.{{DOMAIN}}/getdrop.no/v2/{{resource}}transactions>; rel="successor-version"
3. Authentication & Authorization
3.1 Authentication Methods
Drop supports two auth mechanisms depending on the client:
Primary: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 JWT (OAuth2 / OIDC)Token:
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
- Stored in
AsyncStorageon device - Sent as
Authorization: Bearer {token}header
JWT Claims:Claims (HS256, signed with JWT_SECRET):
{
"sub": "user-uuid"usr_a1b2c3d4e5f6a7b8",
"email": "[email protected]",
"tenant_id"role": "tenant-uuid",
"roles": ["{{ROLE_1}}", "{{ROLE_2}}"],
"scopes": ["{{SCOPE_1}}:read", "{{SCOPE_1}}:write"]user",
"iat": 1700000000,
"exp": 17000036001700086400
}
Token Lifetimes:
| Token Type | Lifetime | Storage |
|---|---|---|
BankID OIDC id_token |
Short-lived (BankID-managed) | Not stored — extracted claims only |
Refresh Flow:
POST /api/auth/refresh
Cookie: refresh_token=drop_token={{REFRESH_TOKEN}}CURRENT_JWT}
→ 200: { "access_token"data": { "userId": "usr_...", "email": "...", "expires_in"role": 3600"user" } }
→ 401: Refresh tokenToken expired — re-authenticate via BankID
3.2 APIBankID KeysOIDC Authentication (forPrimary)
BankID is the sole authentication method (email/password returns 410 Gone — permanently removed).
X-API-Key:1. ak_live_{{KEY_PREFIX_SHOWN_TO_USER}}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
API keys scoped to specific permissionsPrefixed:ak_live_(production),ak_test_(test)Rotate via:POST /api-keys/{id}/rotate
3.3 OAuth2Authorization ScopesRules
| Description | ||
|---|---|---|
|
||
|
||
| ||
|
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 ( |
Bearer {JWT} |
Cookie |
Yes (web endpoints) | drop_token={JWT} (set automatically by browser) |
Content-Type |
Yes (POST/ |
application/json |
Accept |
No | application/json (default) |
X-Request-ID |
Recommended | UUID v4 — echoed in response for tracing |
X-Idempotency-Key |
UUID v4 — prevents duplicate payment operations | |
Accept-Language |
No | nb (Norwegian Bokmål, default), en, , |
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 |
|
|
| |
|
5. Error Response Format
Drop uses a simplified error envelope (not full RFC 7807)7807 in current implementation, but aligned):
{
"type"error": "https://api.{{DOMAIN}}/errors/{{ERROR_CODE}}"error_code",
"title"message": "Human-readable error title",description "status":(Norwegian 400,or "detail": "Specific, actionable error description",
"instance": "/api/v1/{{resource}}/{{id}}English)",
"traceId": "{{TRACE_ID}}",
"errors"details": [
{
"field": "{{FIELD_NAME}}"amount",
"code": "{{VALIDATION_CODE}}"out_of_range",
"message": "{{FIELD_SPECIFIC_MESSAGE}}"Amount must be between 100 and 50000 NOK"
}
]
}
Standard Error Codes
| HTTP Status | Error |
When |
|---|---|---|
400 |
|
|
400 |
no_bank_account |
No linked bank account found |
400 |
validation_error |
Field validation failures (see details array) |
401 |
unauthorized |
Missing or |
402 |
insufficient_balance |
Bank account balance too low for transaction |
403 |
forbidden |
Authenticated but lacks permission |
|
|
|
|
|
|
409 |
conflict |
Duplicate |
410 |
gone |
|
422 |
|
Business |
429 |
|
Too many requests |
500 |
|
Unexpected server error |
| | |
503 |
|
6. Pagination Strategy
Strategy: Cursor-based (preferred) | Offset-based (legacy support)
Cursor-Based (defaultpagination for all newlist endpoints)
endpoints.
Request:
GET /api/v1/{{resource}}?transactions?page=1&limit=20&after={{CURSOR}}type=remittance&status=completed
Response:
{
"data": [], "pagination":... {
"hasNextPage": true,
"hasPreviousPage": false,
"startCursor": "{{BASE64_CURSOR}}",
"endCursor": "{{BASE64_CURSOR}}",
"limit": 20
}
}
Offset-Based (legacy)
Request:
GET /api/v1/{{resource}}?page=1&limit=20
Response:
{
"data": [],
"pagination": {
"page": 1,
"limit": 20,
"total": 500,
"totalPages": 2547
}
}
Limits: Minimum 1, Maximum 10050 items per request.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.
| Limit | Window | Scope | |
|---|---|---|---|
POST /api/auth/register |
per minute | Per IP | |
POST |
per minute | Per |
|
POST |
per minute | Per |
|
POST /api/transactions/qr-payment |
per minute | Per |
|
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
X-RateLimit-Limit: {{LIMIT}}
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1700003600
Retry-After: 37
{
"type"error": "https://api.{{DOMAIN}}/errors/rate-limit-exceeded"rate_limited",
"title"message": "RateFor Limitmange Exceeded",forespørsler. "status":Prøv 429,igjen "detail":om "You have exceeded {{N}} requests per minute. Retry after 37 seconds.litt."
}
8. Endpoint Documentation
Resource: {{Resource Name}}Authentication
POST /{{resource}}api/auth/register
Summary: Create a new {{entity}}Drop user account (email + password flow — DEPRECATED; BankID is primary)
Auth: Required | Scope: None
{{resource}}:writeIdempotency:Rate Limit: Required10 —req/min provideper IPX-Idempotency-Key
Request:
POST /api/v1/{{resource}}auth/register HTTP/1.1
Authorization: Bearer {{TOKEN}}
Content-Type: application/json
X-Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
{
"{{field1}}"email": "string (required)"[email protected]",
"{{field2}}": 123,
"{{field3}}"password": "ENUM_VALUE_ASecurePass123",
|"firstName": ENUM_VALUE_B""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": "550e8400-e29b-41d4-a716-446655440000"usr_a1b2c3d4e5f67890",
"{{field1}}"email": "value"[email protected]",
"{{field2}}": 123,
"{{field3}}"firstName": "ENUM_VALUE_A"Amir",
"lastName": "Hodžić",
"dateOfBirth": "1995-03-15",
"kycStatus": "pending",
"createdAt": "2024-01-01T00:2026-02-23T10:00:00.000Z",
"updatedAt": "2024-01-01T00:00:00.000Z"}
}
Error scenarios:
| Scenario | Status | Error Code |
|---|---|---|
Missing required field |
400 | |
Invalid |
400 | |
already registered |
409 | conflict |
| Age < 18 | 422 | validation_error |
| Invalid |
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 | |
|
||
| Too many attempts | 429 | rate_limited |
GET /{{resource}}/:idapi/auth/me
Summary: RetrieveGet athe {{entity}}current byauthenticated IDuser with linked bank accounts
Auth: Required |(cookie Scope:or Bearer)
Response 200 OK:
{
"data": {resource}
"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":read "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
Cache:Summary: Logout and revoke all active sessions
Auth: Required (cookie or Bearer)
Calls ETagrevokeAllSessions()+— invalidates all Last-Modifiedsessionssupportedrecords 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 |
|
tx_<hex16>) |
Response 200 OK:
{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000"tx_rem_a1b2c3d4e5f67890",
"{{field1}}"type": "value"remittance",
"status": "completed",
"sendAmount": 2000,
"sendCurrency": "NOK",
"receiveAmount": 23400,
"receiveCurrency": "RSD",
"exchangeRate": 11.7,
"fee": 10,
"total": 2010,
"recipientName": "Mama Jasmina",
"recipientCountry": "Serbia",
"createdAt": "2024-01-01T00:00:2026-02-17T14:30:00.000Z",
"completedAt": "2026-02-17T14:31:02.000Z"
}
}
Error scenarios:
| Scenario | Status | Error Code |
|---|---|---|
| ||
| 404 | |
|
|
GET /{{resource}}api/transactions/summary
Summary: ListGet {{entities}} with filteringall-time and paginationcurrent-month transaction statistics
Auth: Required | Scope: {{resource}}:read
QueryResponse Parameters: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"
}
| Type | ||||
|---|---|---|---|---|
|
|
Yes | | |
| | |||
| | |||
| | | |
|
|
number |
|||
|
string |
|
||
|
string |
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",
"{{field1}}"name": "...Mama Jasmina",
"country": "RS",
"currency": "RSD",
"bankAccount": "*****5678",
"bankName": "Raiffeisen Serbia",
"createdAt": "2026-01-20T08:00:00.000Z"
}
],
"pagination": { "hasNextPage"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,
"endCursor"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 {CURSOR}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 WebhookIncoming ConfigurationWebhooks (Drop receives)
Drop receives webhooks from external providers. It does not currently send outbound webhooks to third parties.
Sumsub KYC Webhook
Register endpoint:Endpoint: POST / webhooksapi/webhooks/sumsubTest(TBD endpoint:— path pending implementation)
POSTendpoint /webhooks/{id}/testSignature verification:Verification: HMAC-SHA256 with SUMSUB_SECRET_KEY
9.2 Signature Verification
// VerifySumsub webhook authenticitysignature verification
const payload = request.rawBody;
const signaturereceivedDigest = request.headers['X-Webhook-Signature'x-payload-digest'];
const secret = process.env.WEBHOOK_SECRET;SUMSUB_SECRET_KEY;
const expectedexpectedDigest = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
const isValid = crypto.timingSafeEqual(
Buffer.from(signature)receivedDigest),
Buffer.from(expected)expectedDigest)
);
9.3
Events Webhook Eventsreceived:
| Event | Description | |
|---|---|---|
|
Update ; screening_results |
|
|
Update | |
| |
9.4
Sumsub retry policy: 8 attempts over 7h 42m (1m → 2m → 4m → 8m → 15m → 30m → 1h → 2h)
Neonomics Open Banking Webhook Delivery(Phase 2 - — planned)
Timeout:Purpose: 30Async secondsPISP perpayment deliverystatus attemptupdates
10. OpenAPI 3.1 YAML Skeleton
openapi: '3.1.0'
info:
title: '{{API_NAME}}'Drop REST API'
description: '{{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: '{{API_VERSION}}'1.0.0'
contact:
name: '{{TEAM_NAME}}'Drop Engineering'
email: '{{TEAM_EMAIL}}'[email protected]'
license:
name: 'Proprietary'
servers:
- url: 'https://api.{{DOMAIN}}/v{{MAJOR_VERSION}}'getdrop.no/api'
description: 'Production'
- url: 'https:http://api.staging.{{DOMAIN}}/v{{MAJOR_VERSION}}'localhost:3000/api'
description: 'Staging'Development'
security:
- cookieAuth: []
- bearerAuth: []
tags:
- name: '{{Resource}}'Auth'
description: 'OperationsAuthentication onvia {{resource}}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:
/{{resource}}:auth/me:
post:get:
tags: ['{{Resource}}'Auth']
summary: 'CreateGet {{entity}}'current user with bank accounts'
operationId: 'create{{Entity}}getMe'
responses:
'200':
security:description: -'Authenticated bearerAuth:user ['{{resource}}:write']with requestBody:AISP-read required:bank truebalances'
content:
application/json:
schema:
$ref: '#/components/schemas/Create{{Entity}}Request'
responses:
'201':
description: 'Created'
content:
application/json:
schema:
$ref: '#/components/schemas/{{Entity}}'
'400':
$ref: '#/components/responses/ValidationError'MeResponse'
'401':
$ref: '#/components/responses/Unauthorized'
/transactions:
get:
tags: ['409':Transactions']
summary: 'List transactions'
operationId: 'listTransactions'
parameters:
- $ref: '#/components/responses/Conflict'
get:
tags: ['{{Resource}}']
summary: 'List {{entities}}'
operationId: 'list{{Entities}}'
parameters:parameters/page'
- $ref: '#/components/parameters/limit'
- $ref:name: type
in: query
schema:
type: string
enum: ['remittance', '#/components/parameters/after'qr_payment']
- name: status
in: query
schema:
type: string
enum: ['processing', 'completed', 'failed']
responses:
'200':
description: 'OK'
content:
application/json:
schema:
$ref: '#/components/schemas/Paginated{{Entity}}Response'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: 10050
default: 20
after:
name: after
in: query
schema:
type: string
schemas:
{{Entity}}:Transaction:
type: object
required: [id, {{field1}},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: uuiddate-time
CreateRemittanceRequest:
type: object
required: [recipientId, amount]
properties:
recipientId:
type: string
pattern: '^rec_[a-f0-9]{{field1}}: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
Create{{Entity}}Request:
type: object
required: [{{field1}}]
properties:
{{field1}}:
type: string
minLength: 1
maxLength: 255
ProblemDetails: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: uridate-time
title:HealthResponse:
type: object
properties:
status:
type: string
status:enum: ['ok', 'error']
version:
type: string
uptime:
type: integer
detail:db:
type: string
instance:enum: ['connected', 'disconnected']
dbLatencyMs:
type: integer
timestamp:
type: string
traceId:format: date-time
MeResponse:
type: object
properties:
data:
type: object
properties:
id:
type: string
Paginated{{Entity}}Response: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/{{Entity}}'Transaction'
pagination:
type: object
properties:
hasNextPage:page:
type: booleaninteger
endCursor: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:
ValidationError:BadRequest:
description: 'ValidationBad Error'Request'
content:
application/problem+json:
schema:
$ref: '#/components/schemas/ProblemDetails'ErrorResponse'
Unauthorized:
description: 'Unauthorized'Unauthorized — missing or expired token'
content:
application/problem+json:
schema:
$ref: '#/components/schemas/ProblemDetails'ErrorResponse'
Conflict:InsufficientBalance:
description: 'Conflict'Payment Required — bank account balance too low'
content:
application/problem+json:
schema:
$ref: '#/components/schemas/ProblemDetails'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.
GenerationIf tool:SDK {{SDK_TOOL}}generation is introduced (e.g.,Phase openapi-generator, Speakeasy, Fern)
Generated SDKs:3+):
| Language | Package | Registry |
|---|---|---|
| TypeScript/JS | |
npm |
| ||
|
Generation command:
openapi-generator-cli generate \
-i ./api-specification.yaml \
-g typescript-fetch \
-o ./sdk/typescript \
--additional-properties=npmName=@{{org}}/{{sdk-name}}@alai/drop-client
12. API Changelog
| Version | Date | Change Type | Description |
|---|---|---|---|
|
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 / |
|
| ||
| | ||
| Removed | |
Approval
| Role | Name | Date | Signature |
|---|---|---|---|
| Author | Petter Graff | 2026-02-23 | |
| API Consumer Rep | |||
| Security Review | |||
| Tech Lead | John (AI Director) |