Skip to main content

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 | Approved Reviewers: {{REVIEWERS}}Alem Bašić (CEO), John (AI Director) Spec Format: OpenAPI 3.1

Document History

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

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:

{{CONSUMER_DESCRIPTION}}
  • drop-web — Next.js 15 web app (e.g.,BFF internalpattern; frontend,cookie-based partnerauth; systems,calls publicvia developers)fetch from server components and client components)
  • drop-mobile — Expo SDK 54 React Native app (Bearer token auth; calls via fetch from mobile client)

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

Response Envelope (all responses):

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

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

Base URLs:

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

2. API Versioning Strategy

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

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

Versioning rules:

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

Deprecation Policy:

  • Deprecated endpoints marked with Deprecation and Sunset headers
  • Minimum {{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 AsyncStorage on 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
AccessWeb TokenJWT (cookie) {{ACCESS_TTL}}24 (e.g., 1h)hours Memory only (not localStorage)
Refresh Token{{REFRESH_TTL}} (e.g., 30d)HttpOnlyhttpOnly cookie
APIMobile KeyJWT Non-expiring7 (rotatable)days SecureAsyncStorage vault(encrypted)
BankID OIDC id_tokenShort-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)

server-to-server)

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 permissions
  • Prefixed: ak_live_ (production), ak_test_ (test)
  • Rotate via: POST /api-keys/{id}/rotate

3.3 OAuth2Authorization ScopesRules

AftersuccessfulPOST
ScopeRole Description GrantableGranted toWhen
{{resource}}:readuser ReadStandard {{resource}}registered datauser AllDefault authon usersBankID registration
{{resource}}:writemerchant Create/updateCan {{resource}}accept QR payments {{ROLE_REQUIRED}}
{{resource}}:delete/api/merchants/registerDelete {{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

orN/A
Header Required Description
Authorization Yes (except publicmobile endpoints) Bearer {JWT}
CookieYes (web endpoints)drop_token={JWT} (set automatically by browser)
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 YesRecommended (POST mutations) UUID v4 — prevents duplicate payment operations
Accept-Language No nb (Norwegian Bokmål, default), en, nobs, desq — 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
X-Response-TimeContent-Type Server processing time in ms
Cache-Controlapplication/jsonCaching directives
ETagEntity tag for conditional requests

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

(loggedto
HTTP Status Error TypeCode When to UseUsed
400 validation-errorbad_request RequestMissing/invalid body/paramsJSON failbody or parameters
400no_bank_accountNo linked bank account found
400validation_errorField validation failures (see details array)
401 unauthorized Missing or invalidexpired authentication token
402insufficient_balanceBank account balance too low for transaction
403 forbidden Authenticated but lacks permission
404403 not-foundkyc_required Resource doesKYC not existapproved; transaction blocked
405404 method-not-allowednot_found HTTP methodResource not supportedfound or not owned by user
409 conflict Duplicate orresource state(e.g., conflictemail already registered)
410 gone ResourcePermanently permanentlyremoved deletedendpoint (e.g., email/password auth)
422 business-rule-violationvalidation_error Business logicrule rejectionviolation (unsupported currency corridor, etc.)
429 rate-limit-exceededrate_limited Too many requests
500 internal-errorinternal_error Unexpected server error
502bad-gatewayUpstream service failureSentry)
503 service-unavailableservice_unavailable PlannedDatabase downtimeunreachable or(health overloadcheck only)

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.

TierEndpoint Group Limit Window Scope
AnonymousPOST /api/auth/register {{N}}10 req per minute Per IP
AuthenticatedPOST (free)/api/auth/login {{N}}10 req per minute Per API keyIP
AuthenticatedPOST (paid)/api/transactions/remittance {{N}}10 req per minute Per API keyIP
AdminPOST /api/transactions/qr-payment {{N}}10 req per minute Per userIP
GET /api/rates120 reqper minutePer IP
GET /api/rates/[currency]120 reqper minutePer IP
All other authenticated endpointsNo 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: {{resource}}:writeNone Idempotency:Rate Limit: Required10 req/min provideper X-Idempotency-KeyIP

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:

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": "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 {{field1}}field 400 validation-errorbad_request
{{field1}}Invalid exceedsJSON max lengthbody 400 validation-errorbad_request
DuplicateEmail {{unique_field}}already registered 409 conflict
Age < 18422validation_error
Invalid enumname value(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 password 400 validation-errorbad_request
BusinessInvalid rule: {{RULE_DESCRIPTION}}credentials 422401 business-rule-violationunauthorized
Too many attempts429rate_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-Modifiedsessions supportedrecords 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: Required

Path Parameters:

Parameter Type Description
id UUIDstring TheTransaction {{entity}}ID unique(format: identifiertx_<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
Invalid UUID format400validation-error
{{entity}}Transaction not found 404 not-foundnot_found
AccessTransaction belongs to other tenant's datauser 403404 forbiddennot_found

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"
}
[1-100]
ParameterField Type DefaultRequired DescriptionNotes
limittype integerstring Yes 20remittance Items per page
afterstringCursor for next page
beforestringCursor for previous page
sortstringcreatedAt:descSort:or {field}:{asc|desc}qr_payment
{{FILTER_1}}amount stringnumber Yes FilterMust bybe {{FILTER_1}}positive
{{FILTER_2}}currency string (ISO8601) No FilterDefaults by date range:to after:DATENOK
searchrecipientId string Conditional Full-textRequired searchfor 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"
}
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",
      "{{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"
}
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": 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
}
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 {CURSOR}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 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/sumsub Test(TBD endpoint: POSTendpoint /webhooks/{id}/testpath pending implementation) Signature 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 Events

received:

Event Description PayloadAction
{{entity}}.createdapplicantReviewed {{entity}}KYC createdreview completed Update {id,users.kyc_status; ...}insert screening_results
{{entity}}.updatedapplicantPending {{entity}}Verification updatedin progress Update {id,users.kyc_status changes:= {...}}
{{entity}}.deleted{{entity}} deleted{id, deletedAt}'pending'

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 attempt

  • updates
  • Retries:Events: 5Payment attemptscompleted, withpayment exponentialfailed, backoffpayment reversed Implementation: TBD — requires Neonomics contract (1min, 5min, 30min, 2h, 12h)
  • Success: Any 2xx response
  • Dead delivery: Alert and suspend after 5 consecutive failures
ADR-013)


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 @{{ORG}}/{{sdk-name}}@alai/drop-client npm
Python{{org}}-{{sdk-name}}PyPI
Gogithub.com/{{org}}/{{sdk-name}}pkg.go.dev(private)

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
{{API_VERSION}}1.0.0 {{DATE}}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 Added POST /{{resource}}api/transactions/disclosure endpoint— PSD2 pre-payment fee disclosure
{{API_VERSION}}1.0.0 {{DATE}}Changed{{FIELD}} is now optional (was required)
{{API_VERSION}}{{DATE}}DeprecatedGET /{{old-resource}} — use GET /{{new-resource}}
{{API_VERSION}}{{DATE}}2026-01-01 Removed DELETEPOST /{{old-resource}}api/auth/login email/password auth deprecated sincein {{DATE}}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)