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
| Version | Date | Author | Changes |
|---|---|---|---|
| 0.1 | 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;internalcookie-basedfrontend,auth;partnercallssystems,viapublicdevelopers)fetchfrom server components and client components)drop-mobile— Expo SDK 54 React Native app (Bearer token auth; calls viafetchfrom mobile client)
Design Philosophy: REST + JSON | Resource-oriented | Stateless | Idempotent where possible | RFC 7807 error format | PSD2-compliant disclosure before payment | Dual-driver DB (SQLite dev / PostgreSQL prod)
Response Envelope (all responses):
// Success
{ "data": { ... } }
// Error
{ "error": "error_code", "message": "Human-readable message", "details": [...] }
Base URLs:
| Environment | Base URL |
|---|---|
| Production | https://api. |
| Staging | https://api.staging.{{DOMAIN}}/v{{MAJOR_VERSION}} |
| Development | http://localhost: |
2. API Versioning Strategy
Strategy: URL path versioning — /api/v{MAJOR}
Current version: v1 (implicit — routes are at /api/... without explicit version prefix in current codebase)
Versioning rules:
- MAJOR version (v1 → v2): Breaking changes — new base path, deprecation notice ≥ 6 months
- MINOR additions: Non-breaking — new optional fields, new endpoints — no version bump
- Patch: Bug fixes — no schema changes
Deprecation Policy:
- Deprecated endpoints marked with
DeprecationandSunsetheaders - Minimum
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 JavaScriptCookie flags:httpOnly,sameSite=lax,secure(production)
Mobile (Expo) — Bearer Token:JWT (OAuth2 / OIDC)
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
Stored inAsyncStorageon deviceSent asAuthorization: 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 |
|---|---|---|
| Refresh Token | {{REFRESH_TTL}} (e.g., 30d) | HttpOnly cookie |
|
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
- API keys scoped to
BankIDspecificauthorizationpermissions - Prefixed:
2. User completes BankIDak_live_(web:production),browserak_test_redirect;(test) - Rotate
expo-web-browservia:deep link) 3. BankID redirects toPOST /api/auth/bankid/callback?code=AUTH_CODEapi-keys/{id}/rotate
3.3 AuthorizationOAuth2 RulesScopes
| Description | ||
|---|---|---|
|
||
|
||
|
Delete {{resource}} | {{ROLE_REQUIRED}} |
admin:* |
Full admin access | Admin users only |
KYC Gate: Endpoints marked KYC Required enforce kyc_status = 'approved' — returns 403 if pending or rejected.
4. Common Headers
Request Headers
| Header | Required | Description |
|---|---|---|
Authorization |
Yes ( |
Bearer {JWT} |
| |
|
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 |
UUID v4 — prevents duplicate |
|
Accept-Language |
No | en, , — for localized error messages |
Response Headers
| Header | Description |
|---|---|
X-Request-ID |
Echo of request ID (or generated |
X-RateLimit-Limit |
Rate limit ceiling |
X-RateLimit-Remaining |
Remaining requests in current window |
X-RateLimit-Reset |
Unix timestamp when rate limit resets |
|
Server processing time in ms |
|
Caching directives |
ETag |
Entity 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
| HTTP Status | Error |
When |
|---|---|---|
400 |
|
|
| | |
| | |
401 |
unauthorized |
Missing or |
| | |
403 |
forbidden |
Authenticated but lacks permission |
|
|
|
|
|
|
409 |
conflict |
Duplicate |
410 |
gone |
|
422 |
|
Business |
429 |
|
Too many requests |
500 |
|
Unexpected server error |
502 |
bad-gateway |
Upstream service failure |
503 |
|
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.
| Limit | Window | Scope | |
|---|---|---|---|
Anonymous |
per minute | Per IP | |
(free) |
per minute | Per |
|
(paid) |
per minute | Per |
|
Admin |
per minute | Per | |
| |||
| |||
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:
| |||
| |||
| |||
| |||
| | ||
|
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 {{field1}} |
400 | |
{{field1}} |
400 | |
{{unique_field}} |
409 | conflict |
| 400 | validation-error |
|
| Business rule: {{RULE_DESCRIPTION}} | 422 | |
|
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:
| ||
| ||
|
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}}:readOK: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: balanceETagvalues 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:
| | | |
| | | |
| | | |
| | |
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 |
|
|
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 |
|---|---|---|
| 400 | validation-error |
|
| {{entity}} not found | 404 | |
|
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"
}
| Type | ||||
|---|---|---|---|---|
|
|
Items per page | ||
after |
string |
— | Cursor for next page | |
before |
string |
— | Cursor for previous page | |
sort |
string |
createdAt:desc |
Sort: |
|
|
string |
|||
|
string (ISO8601) |
|
||
|
string |
search |
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"
}
| |||
| |||
| | ||
|
Business Logic (executed atomically):
Verifyrecipient_idbelongs to userLook up exchange rate for recipient's currencyVerify bank account exists and balance >= (amount + fee)Fee =amount × 0.005Debitbank_accounts.balanceby(amount + fee)(cached AISP balance update)Inserttransactionsrecord withstatus = 'processing'
Response 201 Created:
{
"data": {
"id": "tx_rem_a1b2c3d4e5f67890",
"type": "remittance",
"status": "processing",
"sendAmount": 2000,
"sendCurrency": "NOK",
"receiveAmount": 23400,
"receiveCurrency": "RSD",
"exchangeRate": 11.7,
"fee": 10,
"feePercent": 0.5,
"total": 2010,
"recipientName": "Mama Jasmina",
"recipientCountry": "Serbia",
"fromAccount": "DNB",
"eta": "1-2 business days",
"createdAt": "2026-02-23T10:00:00.000Z"
}
}
Error scenarios:
| ||
| ||
| ||
| ||
| ||
|
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
}
| |||
|
Business Logic (executed atomically):
Verify merchant exists and isactiveGet user's primary bank accountFee =amount × merchant.fee_rate(default 1%)Debitbank_accounts.balanceby(amount + fee)Inserttransactionsrecord withstatus = 'completed'(instant)
Response 201 Created:
{
"data": {
"id": "tx_qr_a1b2c3d4e5f67890",
"type": "qr_payment",
"status": "completed",
"amount": 129,
"currency": "NOK",
"fee": 1.29,
"feePercent": 1,
"merchantName": "Ahmetov Kebab",
"merchantId": "mer_a1b2c3d4e5f67890",
"fromAccount": "DNB",
"createdAt": "2026-02-23T10:00:00.000Z"
}
}
GET /api/transactions/[id]/receipt
Summary: Get a transaction receipt with full disclosure details
Auth: Required
Response 200 OK:
{
"data": {
"transactionId": "tx_rem_a1b2c3d4e5f67890",
"date": "2026-02-17T14:30:00.000Z",
"type": "remittance",
"amount": 2000,
"currency": "NOK",
"fee": 10,
"exchangeRate": 11.7,
"receiveAmount": 23400,
"receiveCurrency": "RSD",
"recipient": {
"name": "Mama Jasmina",
"country": "RS"
},
"reference": "tx_rem_a1b2c3d4e5f67890",
"status": "completed",
"estimatedCompletion": null,
"completedAt": "2026-02-17T14:31:02.000Z"
}
}
Resource: Recipients
GET /api/recipients
Summary: List user's saved remittance recipients
Auth: Required
Query Parameters: page (default 1), limit (default 20, max 50)
Bank account numbers are masked in response (e.g., *****5678).
Supported destination countries: RS (Serbia), BA (Bosnia & Herzegovina), PL (Poland), PK (Pakistan), TR (Turkey)
Response 200 OK:
{
"data": [
{ "id": "rec_a1b2c3d4e5f67890"...", "name"{{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"
}
| | ||
| | ||
| |||
| |||
|
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:
| ||
|
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:
| | |
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 requestIDs 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
}
| | |
| | |
| | |
| |
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"
}
| | ||
| |||
| |||
|
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:
| |
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 (setsdeleted_attimestamp onusersrow)Revokes all active sessionsCreatesdata_access_requestsrecord (type=erasure,status=completed)Data is retained for 5 years per AML/KYC legal requirements (hvitvaskingsloven §30)
Response 200 OK:
{
"message": "Account scheduled for deletion",
"retentionNote": "Data retained for 5 years per AML requirements"
}
GET /api/consents
Summary: List user's GDPR consent records
Auth: Required
Response 200 OK:
{
"data": [
{
"id": "con_a1b2c3d4e5f67890",
"user_id": "usr_a1b2c3d4e5f67890",
"consent_type": "terms",
"granted": 1,
"granted_at": "2026-01-15T08:30:00.000Z",
"withdrawn_at": null,
"ip_address": "192.0.2.1"
}
]
}
POST /api/consents
Summary: Grant or withdraw a consent
Auth: Required
Request:
{
"consentType": "marketing",
"granted": true
}
| | ||
| |
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."
}
| | ||
| |||
|
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}/testVerification:Signature verification: HMAC-SHA256
9.2 SUMSUB_SECRET_KEYSignature 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 | |
|---|---|---|
|
{id, |
|
|
{id, changes: {...}} |
|
|
{{entity}} |
{id, deletedAt} |
9.4 Webhook Delivery
Sumsub retry policy:Timeout:830 seconds per delivery attempt- Retries: 5 attempts
overwith7hexponential42mbackoff (1m1min,→5min,2m30min,→2h,4m12h) Purpose:Success:AsyncAnyPISP2xxpaymentresponse- Dead
updatesEvents:delivery:PaymentAlertcompleted,andpaymentsuspendfailed,afterpayment5reversedconsecutiveImplementation:failures
Neonomics Open Banking Webhook (Phase 2 — planned)
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:
| Language | Package | Registry |
|---|---|---|
| TypeScript/JS | |
npm |
| Python | {{org}}-{{sdk-name}} |
PyPI |
| Go | github.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 |
|---|---|---|---|
|
|||
| | ||
| Added | POST / |
|
|
Changed | {{FIELD}} is now optional (was required) |
|
{{API_VERSION}} |
{{DATE}} | Deprecated | GET /{{old-resource}} — use GET /{{new-resource}} |
{{API_VERSION}} |
{{DATE}} | Removed | |
Approval
| Role | Name | Date | Signature |
|---|---|---|---|
| Author | |||
| API Consumer Rep | |||
| Security Review | |||
| Tech Lead |