Skip to main content

API Specification

API Specification

Project: Drop{{PROJECT_NAME}} API Name: Drop REST API{{API_NAME}} Version: 1.0{{API_VERSION}} Date: 2026-02-23{{DATE}} Author: Petter Graff, Senior Enterprise Architect{{AUTHOR}} Status: Draft | In Review | Approved Reviewers: Alem Bašić (CEO), John (AI Director){{REVIEWERS}} Spec Format: OpenAPI 3.1

Document History

extractedfromsourcecodeanalysis
Version Date Author Changes
0.1 2026-02-23{{DATE}} Petter Graff{{AUTHOR}} Initial draft
{{VERSION}} {{DATE}} {{AUTHOR}} {{CHANGE_SUMMARY}}

1. API Overview

Purpose: Drop{{API_PURPOSE}} 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{{CONSUMER_DESCRIPTION}} (BFFe.g., pattern;internal cookie-basedfrontend, auth;partner callssystems, viapublic fetchdevelopers) 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{{DOMAIN}}/v{{MAJOR_VERSION}} (TBD — awaiting domain config)
Staging TBD — requires staging environment setuphttps://api.staging.{{DOMAIN}}/v{{MAJOR_VERSION}}
Development http://localhost:3000/api{{PORT}}/api/v{{MAJOR_VERSION}}

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{{DEPRECATION_PERIOD}} notice before removing deprecated endpoints
  • Deprecation notices sent to: engineering Slack channel + API changelog{{NOTIFICATION_CHANNEL}}

Sunset Header Example:

Deprecation: Sat, 01 Jan 20262025 00:00:00 GMT
Sunset: Sat, 01 Jul 20262025 00:00:00 GMT
Link: <https://api.getdrop.no/{{DOMAIN}}/v2/transactions{{resource}}>; 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:Primary:

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:JWT (OAuth2 / OIDC)

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

JWT Claims (HS256, signed with JWT_SECRET):Claims:

{
  "sub": "usr_a1b2c3d4e5f6a7b8"user-uuid",
  "email": "[email protected]",
  "role"tenant_id": "user"tenant-uuid",
  "roles": ["{{ROLE_1}}", "{{ROLE_2}}"],
  "scopes": ["{{SCOPE_1}}:read", "{{SCOPE_1}}:write"],
  "iat": 1700000000,
  "exp": 17000864001700003600
}

Token Lifetimes:

Token Type Lifetime Storage
WebAccess JWT (cookie)Token 24{{ACCESS_TTL}} hours(e.g., 1h) httpOnlyMemory only (not localStorage)
Refresh Token{{REFRESH_TTL}} (e.g., 30d)HttpOnly cookie
MobileAPI JWTKey 7Non-expiring days(rotatable) AsyncStorageSecure (encrypted)
BankID OIDC id_tokenShort-lived (BankID-managed)Not stored — extracted claims onlyvault

Refresh Flow:

POST /api/auth/refresh
Cookie: drop_token=refresh_token={CURRENT_JWT}{REFRESH_TOKEN}}
→ 200: { "data": { "userId": "usr_...", "email"access_token": "...", "role"expires_in": "user" }3600 }
→ 401: TokenRefresh token expired — re-authenticate
via BankID

3.2 BankIDAPI OIDC AuthenticationKeys (Primary)for server-to-server)

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

1.X-API-Key: GETak_live_{{KEY_PREFIX_SHOWN_TO_USER}}
/api/auth/bankid
    redirects
  • API keys scoped to BankIDspecific authorizationpermissions
  • endpoint
  • Prefixed: 2. User completes BankIDak_live_ (web:production), browserak_test_ redirect;(test)
  • mobile:
  • Rotate expo-web-browservia: deep link) 3. BankID redirects toPOST /api/auth/bankid/callback?code=AUTH_CODEapi-keys/{id}/rotate
  • 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 AuthorizationOAuth2 RulesScopes

successful /api/merchants/register
RoleScope Description GrantedGrantable Whento
user{{resource}}:read StandardRead registered{{resource}} userdata DefaultAll onauth BankID registrationusers
merchant{{resource}}:write CanCreate/update accept QR payments{{resource}} After{{ROLE_REQUIRED}}
POST{{resource}}:delete Delete {{resource}}{{ROLE_REQUIRED}}
admin:*Full admin accessAdmin users only

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


4. Common Headers

Request Headers

or
Header Required Description
Authorization Yes (mobileexcept public endpoints) Bearer {JWT}
CookieYes (web endpoints)drop_token={JWT} (set automatically by browser)N/A
Content-Type Yes (POST/PUT/PATCH) application/json
Accept No application/json (default)
X-Request-ID Recommended UUID v4 — echoed in response for tracing
X-Idempotency-Key RecommendedYes (POST mutations) UUID v4 — prevents duplicate payment operations
Accept-Language No nb (Norwegian Bokmål, default), en, bsno, sqde — for localized error messages

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-TypeX-Response-Time Server processing time in ms
application/jsonCache-ControlCaching directives
ETagEntity tag for conditional requests

5. Error Response Format (RFC 7807)

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

{
  "error"type": "error_code"https://api.{{DOMAIN}}/errors/{{ERROR_CODE}}",
  "message"title": "Human-readable error descriptiontitle",
  (Norwegian"status": or400,
  English)"detail": "Specific, actionable error description",
  "instance": "/api/v1/{{resource}}/{{id}}",
  "details"traceId": "{{TRACE_ID}}",
  "errors": [
    {
      "field": "amount"{{FIELD_NAME}}",
      "code": "out_of_range"{{VALIDATION_CODE}}",
      "message": "Amount must be between 100 and 50000 NOK"{{FIELD_SPECIFIC_MESSAGE}}"
    }
  ]
}

Standard Error Codes

(loggedtoSentry)
HTTP Status Error CodeType When Usedto Use
400 bad_requestvalidation-error Missing/invalidRequest JSONbody/params body or parameters
400no_bank_accountNo linked bank account found
400validation_errorFieldfail validation failures (see details array)
401 unauthorized Missing or expiredinvalid authentication token
402insufficient_balanceBank account balance too low for transaction
403 forbidden Authenticated but lacks permission
403404 kyc_requirednot-found KYCResource does not approved; transaction blockedexist
404405 not_foundmethod-not-allowed ResourceHTTP method not found or not owned by usersupported
409 conflict Duplicate resourceor (e.g.,state email already registered)conflict
410 gone PermanentlyResource removedpermanently endpoint (e.g., email/password auth)deleted
422 validation_errorbusiness-rule-violation Business rulelogic violation (unsupported currency corridor, etc.)rejection
429 rate_limitedrate-limit-exceeded Too many requests
500 internal_errorinternal-error Unexpected server error
502bad-gatewayUpstream service failure
503 service_unavailableservice-unavailable DatabasePlanned unreachabledowntime (healthor check only)overload

6. Pagination Strategy

Strategy: Cursor-based (preferred) | Offset-based pagination(legacy support)

Cursor-Based (default for all listnew endpoints.

endpoints)

Request:

GET /api/transactions?page=1&v1/{{resource}}?limit=20&type=remittance&status=completedafter={{CURSOR}}

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": 47500,
    "totalPages": 25
  }
}

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


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 GroupTier Limit Window Scope
POST /api/auth/registerAnonymous 10{{N}} req per minute Per IP
POSTAuthenticated /api/auth/login(free) 10{{N}} req per minute Per IPAPI key
POSTAuthenticated /api/transactions/remittance(paid) 10{{N}} req per minute Per IPAPI key
POST /api/transactions/qr-paymentAdmin 10{{N}} req per minute Per IP
GET /api/rates120 reqper minutePer IP
GET /api/rates/[currency]120 reqper minutePer IP
All other authenticated endpointsNo explicit limit (DB-backed)user

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

{
  "error"type": "rate_limited"https://api.{{DOMAIN}}/errors/rate-limit-exceeded",
  "message"title": "ForRate mangeLimit forespørsler.Exceeded",
  Prøv"status": igjen429,
  om"detail": litt."You have exceeded {{N}} requests per minute. Retry after 37 seconds."
}

8. Endpoint Documentation

Resource: Authentication{{Resource Name}}

POST /api/auth/register{{resource}}

Summary: Create a new Drop user account (email + password flow — DEPRECATED; BankID is primary){{entity}} Auth: NoneRequired | Scope: {{resource}}:write Rate Limit:Idempotency: 10Required req/min perprovide IPX-Idempotency-Key

Request:

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

{
  "email"{{field1}}": "[email protected]"string (required)",
  "password"{{field2}}": 123,
  "{{field3}}": "SecurePass123",ENUM_VALUE_A "firstName":| "Amir",
  "lastName": "Hodžić",
  "phone": "+4740474251",
  "dateOfBirth": "1995-03-15"ENUM_VALUE_B"
}

Validation:

FieldTypeRequiredRules
emailstringYesRFC email format; unique
passwordstringYesMin 8 chars; must contain letters + digits
firstNamestringYes1–100 chars; at least one letter; no HTML/script
lastNamestringYesSame as firstName
phonestringNo+XXXXXXXXXXXX international format (8–15 digits)
dateOfBirthstringYesISO date; age >= 18 years

Response 201 Created:

{
  "data": {
    "id": "usr_a1b2c3d4e5f67890"550e8400-e29b-41d4-a716-446655440000",
  "email"{{field1}}": "[email protected]"value",
  "firstName"{{field2}}": 123,
  "{{field3}}": "Amir",
    "lastName": "Hodžić",
    "dateOfBirth": "1995-03-15",
    "kycStatus": "pending"ENUM_VALUE_A",
  "createdAt": "2026-02-23T10:2024-01-01T00:00:00.000Z",
  }"updatedAt": "2024-01-01T00:00:00.000Z"
}

Error scenarios:

Scenario Status Error Code
Missing required field{{field1}} 400 bad_requestvalidation-error
Invalid{{field1}} JSONexceeds bodymax length 400 bad_requestvalidation-error
EmailDuplicate already registered{{unique_field}} 409 conflict
AgeInvalid <enum 18value400validation-error
Business rule: {{RULE_DESCRIPTION}} 422 validation_error
Invalid name (HTML/script)422validation_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:

ScenarioStatusError Code
Missing email or password400bad_request
Invalid credentials401unauthorized
Too many attempts429rate_limitedbusiness-rule-violation

GET /api/auth/me{{resource}}/:id

Summary: GetRetrieve thea current{{entity}} authenticatedby user with linked bank accountsID Auth: Required (cookie| or Bearer)

ResponseScope: 200{{resource}}:read OK:Cache:

{
  "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: balanceETag 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()Last-Modified — 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:

ParameterTypeDefaultDescription
pageinteger1Page number (min 1)
limitinteger20Items per page (min 1, max 50)
typestringFilter: remittance or qr_payment
statusstringFilter: 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: Requiredsupported

Path Parameters:

Parameter Type Description
id stringUUID TransactionThe ID{{entity}} (format:unique tx_<hex16>)identifier

Response 200 OK:

{
  "data": {
    "id": "tx_rem_a1b2c3d4e5f67890"550e8400-e29b-41d4-a716-446655440000",
  "type"{{field1}}": "remittance",
    "status": "completed",
    "sendAmount": 2000,
    "sendCurrency": "NOK",
    "receiveAmount": 23400,
    "receiveCurrency": "RSD",
    "exchangeRate": 11.7,
    "fee": 10,
    "total": 2010,
    "recipientName": "Mama Jasmina",
    "recipientCountry": "Serbia"value",
  "createdAt": "2026-02-17T14:30:2024-01-01T00:00:00.000Z",
    "completedAt": "2026-02-17T14:31:02.000Z"
  }
}

Error scenarios:

Scenario Status Error Code
TransactionInvalid UUID format400validation-error
{{entity}} not found 404 not_foundnot-found
Transaction belongsAccess to other usertenant's data 404403 not_foundforbidden

GET /api/transactions/summary{{resource}}

Summary: GetList all-time{{entities}} with filtering and current-month transaction statisticspagination Auth: Required | Scope: {{resource}}:read

ResponseQuery 200 OK:Parameters:

{
  "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"
}
integer or
FieldParameter Type RequiredDefault NotesDescription
typelimit stringYes[1-100] remittance20 Items per page
afterstringCursor for next page
beforestringCursor for previous page
sortstringcreatedAt:descSort: qr_payment{field}:{asc|desc}
amount{{FILTER_1}} numberstring Yes MustFilter beby positive{{FILTER_1}}
currency{{FILTER_2}} string (ISO8601) No DefaultsFilter toby date range: NOKafter:DATE
recipientIdsearch string Conditional RequiredFull-text for remittancesearch

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"
}
FieldTypeRequiredValidation
recipientIdstringYesMust belong to authenticated user
amountnumberYes100–50,000 NOK; max 2 decimal places
currencystringNoDefaults to NOK
bankAccountIdstringNoDefaults 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:

ScenarioStatusError Code
Missing/invalid fields400bad_request
No linked bank account400no_bank_account
Balance too low402insufficient_balance
KYC not approved403kyc_required
Recipient not found404not_found
Unsupported currency corridor422validation_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
}
FieldTypeRequiredValidation
merchantIdstringYesMust be an active merchant
amountnumberYes1–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"{{field1}}": "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"
}
FieldTypeRequiredValidation
namestringYesvalidateName() — 1–100 chars, no HTML/script
countrystringYesMust be in: RS, BA, PL, PK, TR
currencystringYesMust match country's supported currency
bankAccountstringYesStored as-is; masked on read
bankNamestringNoSanitized 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:

ScenarioStatusError Code
Recipient not found404not_found
Recipient belongs to other user404not_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:

ParameterTypeDescription
currencystringTarget 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"hasNextPage": true,
    "emailEnabled": true,
    "updatedAt"endCursor": "2026-02-23T09:00:00.000Z"
  {{CURSOR}}
}

PATCH /api/settings

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

Request:

{
  "currency": "EUR",
  "language": "en",
  "pushEnabled": false,
  "emailEnabled": true
}
FieldTypeAllowed Values
currencystringEUR, USD, GBP, BAM, CHF, PLN, NOK, RSD, TRY, PKR
languagestringnb, en, bs, sq
pushEnabledbooleantrue, false
emailEnabledbooleantrue, 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"
}
FieldTypeRequiredValidation
businessNamestringYesvalidateName()
orgNumberstringYesExactly 9 digits; unique across merchants
addressstringNoSanitized to max 300 chars
bankAccountstringYesMerchant 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:

ParameterTypeValues
periodstringtoday (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
}
FieldTypeRequiredAllowed Values
consentTypestringYesterms, privacy, marketing, cookies_analytics, cookies_marketing
grantedbooleanYestrue = 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."
  }
FieldTypeRequiredValidation
categorystringYestransaction, service, fees, privacy, technical, other
subjectstringYesMax 200 chars; sanitized
descriptionstringYesMax 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 IncomingWebhook Webhooks (Drop receives)Configuration

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

Sumsub KYC Webhook

Endpoint:endpoint: POST /api/webhooks/sumsubwebhooks (TBDTest endpoint: endpointPOST path pending implementation)/webhooks/{id}/test Verification:Signature verification: HMAC-SHA256

with

9.2 SUMSUB_SECRET_KEY

Signature Verification

// SumsubVerify webhook signature verificationauthenticity
const payload = request.rawBody;
const receivedDigestsignature = request.headers['x-payload-digest'X-Webhook-Signature'];
const secret = process.env.SUMSUB_SECRET_KEY;WEBHOOK_SECRET;

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

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

9.3 Webhook Events received:

=
Event Description ActionPayload
applicantReviewed{{entity}}.created KYC{{entity}} review completedcreated Update{id, users.kyc_status; insert screening_results...}
applicantPending{{entity}}.updated Verification{{entity}} in progressupdated Update{id, changes: {...}}
users.kyc_status{{entity}}.deleted {{entity}} 'pending'deleted{id, deletedAt}

9.4 Webhook Delivery

  • Sumsub retry policy:Timeout: 830 seconds per delivery attempt
  • Retries: 5 attempts overwith 7hexponential 42mbackoff (1m1min, 5min, 2m30min, 2h, 4m12h)
  • → 8m → 15m → 30m → 1h → 2h)

    Neonomics Open Banking Webhook (Phase 2 — planned)

  • Purpose:Success: AsyncAny PISP2xx paymentresponse
  • status
  • Dead updates Events:delivery: PaymentAlert completed,and paymentsuspend failed,after payment5 reversedconsecutive Implementation:failures
  • TBD — requires Neonomics contract (ADR-013)


10. OpenAPI 3.1 YAML Skeleton

openapi: '3.1.0'

info:
  title: 'Drop REST API'{{API_NAME}}'
  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.{{API_DESCRIPTION}}'
  version: '1.0.0'{{API_VERSION}}'
  contact:
    name: 'Drop Engineering'{{TEAM_NAME}}'
    email: '[email protected]'{{TEAM_EMAIL}}'
  license:
    name: 'Proprietary'

servers:
  - url: 'https://api.getdrop.no/api'{{DOMAIN}}/v{{MAJOR_VERSION}}'
    description: 'Production'
  - url: 'http:https://localhost:3000/api'api.staging.{{DOMAIN}}/v{{MAJOR_VERSION}}'
    description: 'Development'Staging'

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

tags:
  - name: 'Auth'{{Resource}}'
    description: 'AuthenticationOperations viaon 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){{resource}}'

paths:
  /auth/me:{{resource}}:
    get:post:
      tags: ['Auth'{{Resource}}']
      summary: 'GetCreate current user with bank accounts'{{entity}}'
      operationId: 'getMe'create{{Entity}}'
      responses:security:
        - bearerAuth: ['200'{{resource}}:write']
      description:requestBody:
        'Authenticatedrequired: user with AISP-read bank balances'true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MeResponse'Create{{Entity}}Request'
      responses:
        '201':
          description: 'Created'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/{{Entity}}'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        /transactions:'409':
          $ref: '#/components/responses/Conflict'

    get:
      tags: ['Transactions'{{Resource}}']
      summary: 'List transactions'{{entities}}'
      operationId: 'listTransactions'list{{Entities}}'
      parameters:
        - $ref: '#/components/parameters/page'
        - $ref: '#/components/parameters/limit'
        - name: type
          in: query
          schema:
            type: string
            enum: ['remittance',$ref: 'qr_payment']
        - name: status
          in: query
          schema:
            type: string
            enum: ['processing', 'completed', 'failed']#/components/parameters/after'
      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'Paginated{{Entity}}Response'

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: 50100
        default: 20

    after:
      name: after
      in: query
      schema:
        type: string

  schemas:
    Transaction:{{Entity}}:
      type: object
      required: [id, type, status, amount, currency,{{field1}}, 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-timeuuid
        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:{field1}}:
          type: string
        createdAt:
          type: string
          format: date-time

    RatesResponse:Create{{Entity}}Request:
      type: object
      required: [{{field1}}]
      properties:
        {{field1}}:
          type: string
          minLength: 1
          maxLength: 255

    ProblemDetails:
      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-timeuri
        HealthResponse:title:
          type: object
      properties:string
        status:
          type: stringinteger
        enum: ['ok', 'error']
        version:detail:
          type: string
        uptime:
          type: integer
        db:instance:
          type: string
        enum: ['connected', 'disconnected']
        dbLatencyMs:
          type: integer
        timestamp:traceId:
          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:Paginated{{Entity}}Response:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Transaction'{{Entity}}'
        pagination:
          type: object
          properties:
            page:hasNextPage:
              type: integerboolean
            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:endCursor:
              type: string

  responses:
    BadRequest:ValidationError:
      description: 'BadValidation Request'Error'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'ProblemDetails'
    Unauthorized:
      description: 'Unauthorized — missing or expired token'Unauthorized'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'ProblemDetails'
    InsufficientBalance:Conflict:
      description: 'Payment Required — bank account balance too low'Conflict'
      content:
        application/problem+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'ProblemDetails'

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.

IfGeneration SDKtool: generation is introduced{{SDK_TOOL}} (Phasee.g., 3+):openapi-generator, Speakeasy, Fern) Generated SDKs:

(private)
Language Package Registry
TypeScript/JS @alai/drop-client@{{ORG}}/{{sdk-name}} npm
Python{{org}}-{{sdk-name}}PyPI
Gogithub.com/{{org}}/{{sdk-name}}pkg.go.dev

Generation command:

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

12. API Changelog

Version Date Change Type Description
1.0.0{{API_VERSION}} 2026-02-23InitialAll endpoints documented from source code analysis
1.0.02026-02-16AddedGDPR compliance endpoints (/api/consents, /api/complaints, /api/user/data-export, /api/user/account)
1.0.02026-02-16{{DATE}} Added POST /api/transactions/disclosure{{resource}} — PSD2 pre-payment fee disclosureendpoint
1.0.0{{API_VERSION}} 2026-01-01{{DATE}}Changed{{FIELD}} is now optional (was required)
{{API_VERSION}}{{DATE}}DeprecatedGET /{{old-resource}} — use GET /{{new-resource}}
{{API_VERSION}}{{DATE}} Removed POSTDELETE /api/auth/login{{old-resource}} email/password auth deprecated insince favour of BankID (returns 410){{DATE}}

Approval

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