Event Schema Documentation
Event Schema Documentation
Project:
{{PROJECT_NAME}}Drop Version:{{VERSION}}0.1.0 Date:{{DATE}}2026-02-23 Author:{{AUTHOR}}Platform Architect (AI) Status:Draft |In Review| ApprovedReviewers:{{REVIEWERS}}Alem Bašić (CEO)
Document History
| Version | Date | Author | Changes |
|---|---|---|---|
| 0.1 | Initial draft — event-driven architecture not yet implemented |
1. Event-Driven Architecture Overview
Drop's
graphcurrent LRarchitecture subgraphis "Publishers"a UserService["user-service"]synchronous OrderService["order-service"]monolith PaymentService["payment-service"]— endthere subgraphis "Messageno Broker"internal Broker["{{Kafkaevent /bus RabbitMQor /message AWSqueue SQS(no +BullMQ, SNS}}"]SQS, endor subgraphRabbitMQ). "Consumers"All NotifService["notification-service"]operations AnalyticsService["analytics-service"]are SearchService["search-service"]handled AuditService["audit-service"]synchronously endwithin UserServiceNext.js -->|user.*API events|route Broker
OrderService -->|order.* events| Broker
PaymentService -->|payment.* events| Broker
Broker -->|filtered| NotifService
Broker -->|all events| AnalyticsService
Broker -->|entity events| SearchService
Broker -->|all events| AuditService
handlers.
Event-drivenThis usedocument cases in this system:covers:
DecoupledExternalnotificationswebhook events received from Sumsub (user.createdKYC→statussendupdates)welcome—email)the only production external event sourceSearchInternalindexauditupdateslog(entity.updatedevents→—reindex)database-level event tracking for complianceAuditPlannedtrailevent(allschemamutations—→forauditfuturelog)async processing when an event bus is addedCross-service data sync (order.created → update inventory)
2. MessageExternal BrokerWebhook ConfigurationEvents
2.1 Sumsub — KYC Status Webhook
Broker:Source: , {{Apachesrc/lib/services/mock-sumsub.tsKafkaproduction |Sumsub RabbitMQdocs: |https://docs.sumsub.com/reference/applicant-review
Sumsub SQS/SNSsends |webhook NATSevents |when Upstasha Kafka}}KYC applicant's review status changes.
Version:Endpoint: {{3.x}}POST /api/kyc/webhookHosting:(TBD integration){{Confluent— Cloudpending /Open self-hostedBanking /provider AWS MSK}}
Topic / Queue Naming Convention
{{DOMAIN}}.{{ENTITY}}.{{ACTION}}
Examples:
user.user.created
order.order.status_changed
payment.invoice.generated
notification.email.sent
PatternWebhook rules:signature verification:
- HMAC-SHA256
All lowercase, dot-separatedDomain prefix = service name (withoutusing-serviceSUMSUB_SECRET_KEY). EntityReject=anysingular nounAction = past tense verb (created, updated, deleted, completed)
Topic Configuration
| ||||
| ||||
| ||||
|
3. Event Naming Conventions
| | |
| ||
Event: | ||
|
DoSent NOTwhen use:Sumsub completes review of a KYC applicant.
Present tense (user.user.create— wrong)Generic names (user.user.changed— too vague)Abbreviations (usr.usr.crtd— unreadable)
4. Event Envelope Format (CloudEvents 1.0)
{
"specversion": "1.0",
"type": "{{DOMAIN}}.{{ENTITY}}.{{ACTION}}"applicantReviewed",
"source"reviewStatus": "{{SERVICE_NAME}}"completed",
"id"applicantId": "evt_01HX7M2K5N3P4Q5R6S7T8V9W0"sumsub_applicant_id",
"time"externalUserId": "2024-01-15T10:30:00.000Z"usr_a1b2c3d4",
"datacontenttype": "application/json",
"subject": "{{optional: entity ID}}",
"data"reviewResult": {
"{{field}}"reviewAnswer": "{{value}}GREEN",
"rejectLabels": [],
"reviewRejectType": null
},
"createdAt": "2026-02-23T12:00:00Z"
}
Envelope fields:
| Field | Type | Description | |
|---|---|---|---|
|
string | applicantReviewed |
|
reviewAnswer |
string | GREEN, , RETRY |
Verification outcome |
externalUserId |
string | Drop user ID | Links to Drop users.id |
|
|||
|
|||
| |||
| |||
| | ||
| |||
|
TypeScriptDrop interface:action on receipt:
interfaceswitch CloudEvent<T(reviewAnswer) {
case 'GREEN':
// Update users SET kyc_status = Record<string,'approved' unknown>>WHERE id = externalUserId
break;
case 'RED':
// Update users SET kyc_status = 'rejected' WHERE id = externalUserId
break;
case 'RETRY':
// Update users SET kyc_status = 'pending' WHERE id = externalUserId
// User must resubmit documents
break;
}
Event: applicantPending
Sent when applicant documents are submitted and awaiting review.
{
"type": "applicantPending",
"applicantId": "...",
"externalUserId": "usr_...",
"createdAt": "2026-02-23T12:00:00Z"
}
Drop action: Update users SET kyc_status = 'pending'.
2.2 Open Banking Provider — Payment Webhooks (Planned)
When an Open Banking provider is selected, the following webhook events will be received for PISP payment status updates.
Endpoint: POST /api/payments/webhook (future)
Event: payment.settled
{
"event": "payment.settled",
"paymentId": "provider_payment_id",
"dropTransactionId": "tx_rem_abc123",
"status": "completed",
"settledAt": "2026-02-23T14:30:00Z",
"amount": 2000,
"currency": "NOK"
}
Drop action: UPDATE transactions SET status = 'completed', completed_at = NOW() WHERE id = dropTransactionId
Event: payment.failed
{
"event": "payment.failed",
"paymentId": "provider_payment_id",
"dropTransactionId": "tx_rem_abc123",
"status": "failed",
"failureReason": "InsufficientFunds",
"failedAt": "2026-02-23T14:30:00Z"
}
Drop action: UPDATE transactions SET status = 'failed' WHERE id = dropTransactionId, refund bank account balance.
2.3 BankID — No Webhook Events
BankID OIDC is a synchronous request-response flow. No webhooks are received from BankID. The callback URL (GET /api/auth/bankid/callback) handles the code exchange synchronously.
3. Internal Audit Log Events
Drop maintains an audit_log table for all significant application events. This functions as an internal event log for compliance (AML, GDPR) and security investigations.
3.1 Audit Log Schema
CREATE TABLE audit_log (
id TEXT PRIMARY KEY,
action TEXT NOT NULL, -- Event type (see below)
user_id TEXT, -- Acting user (null for system events)
resource_type TEXT, -- Entity type (user, transaction, secret, etc.)
resource_id TEXT, -- Entity ID
details TEXT, -- JSON blob with event-specific data
ip_address TEXT, -- Client IP at time of action
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
);
3.2 Audit Event Types
| Action | Trigger | Resource Type | Details |
|---|---|---|---|
user_registered |
New user created via BankID | user |
{ kyc_method, auth_provider } |
kyc_approved |
KYC status → approved | user |
{ sumsub_applicant_id, review_answer } |
kyc_rejected |
KYC status → rejected | user |
{ reject_labels, review_answer } |
transaction_created |
Remittance or QR payment initiated | transaction |
{ type, amount, fee, currency } |
transaction_completed |
Payment confirmed by provider | transaction |
{ settled_at, provider_ref } |
transaction_failed |
Payment rejected | transaction |
{ failure_reason } |
session_created |
User logged in | session |
{ auth_method } |
sessions_revoked |
User logged out | session |
{ revoked_count } |
account_deleted |
GDPR erasure request | user |
{ deleted_at, retention_note } |
data_exported |
GDPR data export | user |
{ export_timestamp } |
consent_granted |
GDPR consent given | consent |
{ consent_type, ip_address } |
consent_withdrawn |
GDPR consent revoked | consent |
{ consent_type, ip_address } |
complaint_submitted |
User complaint filed | complaint |
{ category, subject } |
aml_alert_created |
AML monitoring flagged activity | aml_alert |
{ alert_type, severity, transaction_id } |
str_filed |
STR filed with Finanstilsynet | str_report |
{ filed_at, case_number } |
secret_rotated |
Secret key rotated | secret |
{ provider, key_name, rotated_at } |
merchant_registered |
New merchant account | merchant |
{ org_number, business_name } |
3.3 Example Audit Log Entry
{
"id": "aud_1a2b3c4d5e6f7890",
"action": "transaction_created",
"user_id": "usr_abc123",
"resource_type": "transaction",
"resource_id": "tx_rem_xyz789",
"details": {
specversion:"type": "remittance",
"amount": 2000,
"fee": 10,
"currency": "NOK",
"recipient_country": "RS",
"exchange_rate": 11.7
},
"ip_address": "85.20.12.45",
"timestamp": "2026-02-23T14:30:00.000Z"
}
3.4 Querying Audit Logs
-- Recent user activity
SELECT * FROM audit_log
WHERE user_id = '1.0';usr_abc123'
type:ORDER string;BY source:timestamp string;DESC
id:LIMIT string;50;
time:-- string;Security: datacontenttype:all secret rotations
SELECT * FROM audit_log
WHERE action = 'application/json';secret_rotated'
subject?ORDER BY timestamp DESC;
-- AML: suspicious transactions
SELECT al.* FROM audit_log al
JOIN transactions t ON al.resource_id = t.id
WHERE al.action = 'transaction_created'
AND t.send_amount > 50000 -- High-value transactions
AND al.timestamp > NOW() - INTERVAL '24 hours'
ORDER BY al.timestamp DESC;
Retention: Audit logs retained for 5 years per hvitvaskingsloven (Norwegian AML law).
4. Planned Event-Driven Architecture (Future)
When Drop scales beyond MVP, an event bus will decouple synchronous operations:
4.1 Proposed Event Bus
Technology options:
- AWS SQS + SNS (native AWS, fits existing infrastructure)
- BullMQ + Redis (simpler, Node.js native)
Recommendation: SQS for production reliability.
4.2 Planned Event Topics
| Topic | Publisher | Subscribers | Purpose |
|---|---|---|---|
drop.transaction.initiated |
Payment service | PISP provider, Audit, Notification | Trigger payment + notify user |
drop.transaction.settled |
Open Banking webhook handler | Audit, Notification, AML | Update transaction status |
drop.kyc.status_changed |
Sumsub webhook handler | Audit, User service | Update user KYC status |
drop.user.registered |
Auth service | KYC, Notification | Trigger KYC flow |
drop.aml.alert |
AML monitoring | Compliance, Notification | Flag suspicious activity |
4.3 Planned Event Envelope
{
"eventId": string;"evt_unique_id",
data:"eventType": T;"drop.transaction.initiated",
"version": "1.0",
"timestamp": "2026-02-23T14:30:00Z",
"source": "drop-payment-service",
"correlationId": "req_abc123",
"data": {
"transactionId": "tx_rem_xyz",
"userId": "usr_abc",
"amount": 2000,
"currency": "NOK"
}
}
5. Per-EventSlack DocumentationAlert Events (Operational)
Slack
operational
5.1alerts Userfrom Servicesrc/lib/alerts.ts Events
function user.user.created
Published whenas a newsimple userevent accountnotification is created.system:
| Trigger | ||
|---|---|---|
info |
App startup |
Application boots |
info |
|
Graceful shutdown |
critical |
Error spike |
> 5 errors in 60 seconds |
critical |
||
Process | ||
PayloadThese schemaare (JSONfire-and-forget Schema):
{POST "$schema":calls "http://json-schema.org/draft-07/schema#",— "type":no "object",acknowledgement "required":or ["userId",retry "email", "name", "role", "createdAt"],
"properties": {
"userId": { "type": "string", "format": "uuid" },
"email": { "type": "string", "format": "email" },
"name": { "type": "string", "minLength": 1 },
"role": { "type": "string", "enum": ["admin", "user", "viewer"] },
"createdAt": { "type": "string", "format": "date-time" }
}
}
Example event:
{
"specversion": "1.0",
"type": "user.user.created",
"source": "user-service",
"id": "evt_01HX7M2K5N3P4Q5R6S7T8V9W0",
"time": "2024-01-15T10:30:00.000Z",
"datacontenttype": "application/json",
"subject": "usr_01HX7...",
"data": {
"userId": "usr_01HX7...",
"email": "[email protected]",
"name": "Jane Doe",
"role": "user",
"createdAt": "2024-01-15T10:30:00.000Z"
}
}
user.user.updated
Published when user profile data changes.
| |
| |
| |
|
Payload schema:
{
"type": "object",
"required": ["userId", "updatedFields", "updatedAt"],
"properties": {
"userId": { "type": "string" },
"updatedFields": {
"type": "array",
"items": { "type": "string" },
"description": "List of field names that changed"
},
"before": { "type": "object", "description": "Previous values (only changed fields)" },
"after": { "type": "object", "description": "New values (only changed fields)" },
"updatedAt": { "type": "string", "format": "date-time" }
}
}
Example event:
{
"specversion": "1.0",
"type": "user.user.updated",
"source": "user-service",
"id": "evt_01HX8...",
"time": "2024-01-16T08:00:00.000Z",
"datacontenttype": "application/json",
"subject": "usr_01HX7...",
"data": {
"userId": "usr_01HX7...",
"updatedFields": ["name"],
"before": { "name": "Jane Doe" },
"after": { "name": "Jane Smith" },
"updatedAt": "2024-01-16T08:00:00.000Z"
}
}
user.user.deleted
Published when a user account is soft-deleted.
| |
| |
| |
Example event:
{
"specversion": "1.0",
"type": "user.user.deleted",
"source": "user-service",
"id": "evt_01HX9...",
"time": "2024-01-17T12:00:00.000Z",
"datacontenttype": "application/json",
"subject": "usr_01HX7...",
"data": {
"userId": "usr_01HX7...",
"deletedAt": "2024-01-17T12:00:00.000Z",
"reason": "user_requested"
}
}
5.2 Order Service Events
order.order.created
| |
| |
| |
Payload schema:
{
"type": "object",
"required": ["orderId", "userId", "items", "total", "currency", "createdAt"],
"properties": {
"orderId": { "type": "string" },
"userId": { "type": "string" },
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"productId": { "type": "string" },
"quantity": { "type": "integer" },
"unitPrice": { "type": "number" }
}
}
},
"total": { "type": "number" },
"currency": { "type": "string", "pattern": "^[A-Z]{3}$" },
"createdAt": { "type": "string", "format": "date-time" }
}
}
5.3 {{DOMAIN}} Service Events
{{domain}}.{{entity}}.{{action}}
| |
| |
| |
|
Payload schema: TODO: Define JSON Schema
Example event: logic.TODO: Add example
6.Related Dead Letter Queue HandlingDocuments
DLQ naming: {{topic}}.dlq — e.g., user.user.created.dlq
DLQ workflow:
Event Published
↓
Consumer processes
↓ Fails
Retry (exp. backoff: 1s, 2s, 4s, 8s, 16s — max 5 retries)
↓ All retries exhausted
Move to DLQ
↓
Alert fires: PagerDuty P3
↓
On-call investigates
↓
Option A: Fix consumer bug → replay from DLQ
Option B: Skip message (data was invalid) → log + discard
DLQ message format:
{
"originalEvent": { /* original CloudEvent */ },
"failureReason": "Consumer threw: Cannot read property 'id' of undefined",
"attemptCount": 5,
"firstFailedAt": "2024-01-15T10:30:00Z",
"lastFailedAt": "2024-01-15T10:32:00Z",
"consumerGroup": "notification-service-consumer"
}
DLQ retention: 14 days.
DLQ alert threshold: > 10 messages in DLQ within 5 minutes.
7. Event Versioning Strategy
Strategy: Backward-compatible field addition + major version in event type.
Rules:
Adding optional fields: allowed without version bumpRemoving fields: NOT allowed (use deprecation first, remove after all consumers updated)Changing field types: NOT allowed (breaking change)Adding required fields: requires version bumpMajor breaking change: new event typeuser.user.created.v2
Deprecation process:
1. Mark field as deprecated in schema docs
2. Notify all consumer teams
3. Wait 2 sprint cycles (4 weeks minimum)
4. Remove field from schema
5. Update documentation
Schema registry: {{Confluent Schema Registry | AWS Glue Schema Registry | Manual docs}}
Validation: Consumer validates incoming events against pinned schema version.
8. Event Replay Capability
Replay supported: {{Yes — Kafka log retention | No — events are ephemeral}}
Replay scenarios:
BugBackendin consumer → fix bug → replay affected time windowArchitectureNewExternalconsumerServicesonboarded → replay historical events to build initial stateIntegrationDataServicemigration → replay events to new storage
Replay procedure:
Identify topic and time range to replayDesignCoordinate with all consumer teams (replay may cause duplicate side effects)Ensure consumers are idempotent before replaySet consumer offset to target timestamp:kafka-consumer-groups --reset-offsets --to-datetimeRestart consumer with temporary consumer group to avoid affecting production offsetVerify replayed state is correctSwitch production consumer to new state
Retention periods by topic: See topic configuration table in Section 2.
9. Monitoring & Observability
| |||
| |||
|
Dashboard: {{https://monitoring.domain.com/dashboards/events}}
Distributed tracing: All events carry traceparent header (OpenTelemetry W3C Trace Context).
10. Testing Event-Driven Flows
Unit Tests
// Test producer: verify event shape
it('should publish user.created event with correct schema', async () => {
await userService.create(createUserDto);
expect(eventBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: 'user.user.created',
source: 'user-service',
data: expect.objectContaining({
userId: expect.any(String),
email: createUserDto.email,
}),
})
);
});
// Test consumer: verify handler idempotency
it('should not send welcome email twice for duplicate event', async () => {
const event = buildUserCreatedEvent();
await handler.handle(event);
await handler.handle(event); // duplicate
expect(emailService.send).toHaveBeenCalledTimes(1);
});
Integration Tests
// Use real broker in integration tests (testcontainers)
const kafka = await new KafkaContainer('confluentinc/cp-kafka:7.5.0').start();
E2E Tests
Test full event chain: API action → event published → consumer processes → side effect visible.
POST /users → poll for welcome email (SendGrid sandbox) → assert received within 5s
Approval
| Role | Name | Date | Signature |
|---|---|---|---|
| Author | Platform Architect (AI) | 2026-02-23 | |
| Alem | |||
| Bašić |