Event Schema Documentation
Event Schema Documentation
Project:
Drop{{PROJECT_NAME}} Version:0.1.0{{VERSION}} Date:2026-02-23{{DATE}} Author:Platform Architect (AI){{AUTHOR}} Status: Draft | In Review | Approved Reviewers:Alem Bašić (CEO){{REVIEWERS}}
Document History
| Version | Date | Author | Changes |
|---|---|---|---|
| 0.1 | Initial draft |
1. Event-Driven Architecture Overview
graph LR
subgraph "Publishers"
UserService["user-service"]
OrderService["order-service"]
PaymentService["payment-service"]
end
subgraph "Message Broker"
Broker["{{Kafka / RabbitMQ / AWS SQS + SNS}}"]
end
subgraph "Consumers"
NotifService["notification-service"]
AnalyticsService["analytics-service"]
SearchService["search-service"]
AuditService["audit-service"]
end
UserService -->|user.* events| Broker
OrderService -->|order.* events| Broker
PaymentService -->|payment.* events| Broker
Broker -->|filtered| NotifService
Broker -->|all events| AnalyticsService
Broker -->|entity events| SearchService
Broker -->|all events| AuditService
Drop'sEvent-driven currentuse architecturecases isin athis synchronoussystem:
- Decoupled
there is no internal event bus or message queuenotifications (nouser.createdBullMQ,→SQS,sendorwelcomeRabbitMQ). All operations are handled synchronously within Next.js API route handlers.This document covers:External webhook eventsreceived from Sumsub (KYC status updates) — the only production external event sourceemail)InternalSearch index updates (entity.updated → reindex)- Audit trail (all mutations → audit
log events— database-level event tracking for compliancelog) PlannedCross-serviceeventdataschemasync—(order.createdfor→futureupdateasync processing when an event bus is addedinventory)
2. ExternalMessage WebhookBroker EventsConfiguration
Broker: {{Apache Kafka | RabbitMQ | AWS SQS/SNS | NATS | Upstash Kafka}}
Version: {{3.x}}
Hosting: {{Confluent Cloud / self-hosted / AWS MSK}}
2.1Topic Sumsub/ Queue Naming Convention
{{DOMAIN}}.{{ENTITY}}.{{ACTION}}
Examples:
user.user.created
order.order.status_changed
payment.invoice.generated
notification.email.sent
Pattern rules:
- All lowercase, dot-separated
- Domain prefix = service name (without
-service) - Entity = singular noun
- Action = past tense verb (created, updated, deleted, completed)
Topic Configuration
| Topic | Partitions | Replication | Retention | Compaction |
|---|---|---|---|---|
user.user.* |
6 | 3 | 7 days | No |
order.order.* |
12 | 3 | 30 days | No |
payment.invoice.* |
6 | 3 | 90 days | No |
*.*.deleted |
6 | 3 | 30 days | Log compaction |
3. Event Naming Conventions
| Component | Rule | Examples |
|---|---|---|
| Full event type | {domain}.{entity}.{action} |
user.user.created |
| Domain | Lowercase, matches service prefix | user, order, payment |
| Entity | Singular noun, lowercase with underscores | user, order_item, invoice |
| Action | Past-tense verb, lowercase with underscores | created, updated, status_changed, payment_failed |
Do NOT use:
- Present tense (
user.user.create—KYCwrong) - Generic
WebhookSource:src/lib/services/mock-sumsub.ts, production Sumsub docs: https://docs.sumsub.com/reference/applicant-reviewSumsub sends webhook events when a KYC applicant's review status changes.Endpoint:names (POST /api/kyc/webhookTBDuser.user.changed—pendingtooOpenvague) - Abbreviations
provider(usr.usr.crtdintegration)—Webhookunreadable)
4. usingEvent SUMSUB_SECRET_KEY.Envelope RejectFormat any(CloudEvents webhook1.0)
Event: applicantReviewed
Sent when Sumsub completes review of a KYC applicant.
{
"specversion": "1.0",
"type": "applicantReviewed"{{DOMAIN}}.{{ENTITY}}.{{ACTION}}",
"reviewStatus"source": "completed"{{SERVICE_NAME}}",
"applicantId"id": "sumsub_applicant_id"evt_01HX7M2K5N3P4Q5R6S7T8V9W0",
"externalUserId"time": "usr_a1b2c3d4"2024-01-15T10:30:00.000Z",
"reviewResult"datacontenttype": "application/json",
"subject": "{{optional: entity ID}}",
"data": {
"reviewAnswer"{{field}}": "GREEN",
{{value}}"rejectLabels": [],
"reviewRejectType": null
},
"createdAt": "2026-02-23T12:00:00Z"
}
Envelope fields:
| Field | Type | Description | |
|---|---|---|---|
specversion |
string | Yes | Always "1.0" |
type |
string | Yes |
Event type (see naming convention) |
|
string | Yes |
|
|
string | ||
time |
string | Yes | ISO 8601 timestamp (UTC) |
datacontenttype |
string | Yes | Always |
|
Primary entity ID (for routing) | ||
|
Yes | Domain-specific |
DropTypeScript action on receipt:interface:
switchinterface (reviewAnswer)CloudEvent<T = Record<string, unknown>> {
casespecversion: 'GREEN'1.0';
type: string;
source: string;
id: string;
time: string;
datacontenttype: 'application/json';
subject?: //string;
Updatedata: users SET kyc_status = 'approved' 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;T;
}
5. Per-Event Documentation
5.1 User Service Events
Event: applicantPendinguser.user.created
SentPublished when applicanta documentsnew areuser submittedaccount andis awaitingcreated.
| Property | Value |
|---|---|
| Publisher | user-service |
| Consumers | notification-service, analytics-service, audit-service |
| Topic | user.user.created |
| Ordering guarantee | Per user ID (partitioned by subject) |
| Idempotency key | id (event ID) — consumers must deduplicate |
| Retry behavior | Consumer retries up to 5x before DLQ |
Payload schema (JSON Schema):
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["userId", "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.
| Property | Value |
|---|---|
| Publisher | user-service |
| Consumers | search-service, notification-service, analytics-service |
| Topic | user.user.updated |
| Ordering guarantee | Per user ID |
| Idempotency key | id (event ID) |
Payload schema:
{
"type": "applicantPending"object",
"applicantId"required": ["userId", "updatedFields", "updatedAt"],
"properties": {
"userId": { "type": "..."string" },
"externalUserId"updatedFields": {
"type": "usr_..."array",
"createdAt"items": { "type": "2026-02-23T12:00:00Z"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" }
}
}
DropExample action:event: 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"specversion": "payment.settled"1.0",
"paymentId"type": "provider_payment_id"user.user.updated",
"dropTransactionId"source": "tx_rem_abc123"user-service",
"status"id": "completed"evt_01HX8...",
"settledAt"time": "2026-02-23T14:30:00Z"2024-01-16T08:00:00.000Z",
"amount": 2000,
"currency"datacontenttype": "NOK"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"
}
}
Drop action: UPDATE transactions SET status = 'completed', completed_at = NOW() WHERE id = dropTransactionId
Event: payment.faileduser.user.deleted
{Published "event":when "payment.failed",a "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 bankuser 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.soft-deleted.
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
| |
||
| Consumers | order-service, notification-service, analytics-service |
||
| Payload | { |
||
Ordering guarantee |
| | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | ID |
3.3
Example Audit Log Entryevent:
{
"id"specversion": "aud_1a2b3c4d5e6f7890"1.0",
"action": "transaction_created",
"user_id": "usr_abc123",
"resource_type": "transaction",
"resource_id": "tx_rem_xyz789",
"details": {
"type": "remittance"user.user.deleted",
"amount": 2000,
"fee": 10,
"currency"source": "NOK"user-service",
"recipient_country"id": "RS"evt_01HX9...",
"exchange_rate"time": 11.7
}"2024-01-17T12:00:00.000Z",
"ip_address"datacontenttype": "85.20.12.45"application/json",
"timestamp"subject": "2026-02-23T14:30:usr_01HX7...",
"data": {
"userId": "usr_01HX7...",
"deletedAt": "2024-01-17T12:00:00.000Z",
"reason": "user_requested"
}
}
3.45.2 QueryingOrder AuditService LogsEvents
-- Recent user activity
SELECT * FROM audit_log
WHERE user_id = 'usr_abc123'
ORDER BY timestamp DESC
LIMIT 50;
-- Security: all secret rotations
SELECT * FROM audit_log
WHERE action = 'secret_rotated'
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
order.order.created
Publisher |
order-service | ||
Consumers |
payment-service, notification-service, | inventory-service |
|
Topic |
order.order.created |
||
Ordering guarantee |
|||
|
4.3
Payload Planned Event Envelopeschema:
{
"eventId"type": "evt_unique_id"object",
"eventType"required": ["drop.transaction.initiated"orderId", "version": "1.0"userId", "timestamp": "2026-02-23T14:30:00Z"items", "source": "drop-payment-service"total", "correlationId": "req_abc123"currency", "data"createdAt"],
"properties": {
"transactionId"orderId": { "type": "tx_rem_xyz"string" },
"userId": { "usr_abc"type": "string" },
"amount"items": 2000,{
"type": "array",
"items": {
"type": "object",
"properties": {
"productId": { "type": "string" },
"quantity": { "type": "integer" },
"unitPrice": { "type": "number" }
}
}
},
"total": { "type": "number" },
"currency": { "NOK"type": "string", "pattern": "^[A-Z]{3}$" },
"createdAt": { "type": "string", "format": "date-time" }
}
}
5.3 Slack{{DOMAIN}} AlertService Events
(Operational)
Slack operational alerts from src/lib/alerts.ts function as a simple event notification system:
{{domain}}.{{entity}}.{{action}}
Publisher |
{{service-name}} |
|
Consumers |
{{consumer-a, | |
Topic |
{{domain.entity.action}} |
|
Ordering guarantee |
||
| Idempotency key | {{id}} |
ThesePayload areschema: fire-and-forgetTODO: HTTPDefine POSTJSON callsSchema
Example noevent: acknowledgementTODO: orAdd retry logic.example
Related6. DocumentsDead Letter Queue Handling
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 bump
- Removing fields: NOT allowed (use deprecation first, remove after all consumers updated)
- Changing field types: NOT allowed (breaking change)
- Adding required fields: requires version bump
- Major breaking change: new event type
user.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:
BackendBugArchitecturein consumer → fix bug → replay affected time windowExternalNewServicesconsumerIntegrationonboarded → replay historical events to build initial stateServiceDataDesignmigration → replay events to new storage
Replay procedure:
- Identify topic and time range to replay
- Coordinate with all consumer teams (replay may cause duplicate side effects)
- Ensure consumers are idempotent before replay
- Set consumer offset to target timestamp:
kafka-consumer-groups --reset-offsets --to-datetime - Restart consumer with temporary consumer group to avoid affecting production offset
- Verify replayed state is correct
- Switch production consumer to new state
Retention periods by topic: See topic configuration table in Section 2.
9. Monitoring & Observability
| Metric | Alert Threshold | Severity | Channel |
|---|---|---|---|
| Consumer lag (per topic) | > 10,000 messages | P2 | Slack #alerts |
| DLQ depth | > 10 messages / 5min | P3 | Slack #alerts |
| Producer error rate | > 1% / 5min | P1 | PagerDuty |
| Consumer error rate | > 5% / 5min | P2 | PagerDuty |
| Event processing latency P99 | > 5 seconds | P3 | Slack #alerts |
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 | |||
| Architect |