| name | adr-021-kyc-aml-verification-provider-architecture | |||||
|---|---|---|---|---|---|---|
| description | Architecture for external KYC/AML verification provider integration with async flows, multi-provider support, and privacy-first design | |||||
| triggers |
|
|||||
| instructions | Use async verification as default for KYC/AML checks (90%+ of cases). Implement provider abstraction via VerificationProvider interface. Store only verification results and references, never raw PII. All webhooks require HMAC signature validation and idempotency. |
Date: 2025-12-31
Accepted
Meridian requires integration with external Know Your Customer (KYC) and Anti-Money Laundering (AML) verification providers to comply with financial regulations. These providers perform identity verification, document checks, sanctions screening, and risk assessments.
Key challenges include:
- Verification latency: Provider checks range from milliseconds (sanctions screening) to days (manual document review)
- Provider diversity: Different jurisdictions require different providers (e.g., Onfido for UK, Jumio for EU, Persona for US)
- Data sensitivity: PII must be handled according to GDPR, CCPA, and financial services regulations
- Reliability: Verification flows must handle provider outages without blocking customer onboarding
- Audit requirements: Complete audit trail for regulatory reporting and dispute resolution
The system must support both synchronous checks (instant sanctions screening) and asynchronous flows (document verification with manual review).
- Regulatory compliance: Meet KYC/AML requirements across multiple jurisdictions
- Provider flexibility: Support multiple providers without code changes
- Privacy by design: Minimize PII exposure and retention
- Resilience: Handle provider failures gracefully
- Audit trail: Complete traceability for regulatory examinations
- Latency tolerance: Accept appropriate delays for thorough verification
- Cost optimization: Route to cost-effective providers when equivalent
- Async-first with synchronous fallback via provider abstraction layer
- Synchronous-only with timeout-based fallbacks
- Event-sourced verification saga with eventual consistency
- Third-party orchestration (e.g., Alloy, Unit21)
Chosen option: "Async-first with synchronous fallback via provider abstraction layer", because:
- Matches real-world verification timelines (hours to days for document review)
- Provider interface abstraction enables multi-provider support without code changes
- Webhook-based callbacks align with industry standard (all major providers use webhooks)
- Synchronous fallback available for instant checks (sanctions screening)
- Privacy controls enforced at the abstraction layer
- Provider agnostic: Add new providers by implementing interface
- Graceful degradation: Provider outages don't block entire flow
- Cost optimization: Route to cheaper providers for low-risk checks
- Compliance ready: Audit logs capture complete verification history
- Privacy enforced: PII handling rules centralized in abstraction layer
- Increased complexity: Webhook infrastructure requires additional components
- Eventual consistency: Verification status may lag behind provider state
- Testing difficulty: Async flows harder to test end-to-end
- Operational overhead: Webhook endpoint requires monitoring and alerting
Provider abstraction layer with default async verification and optional sync path.
- Good, because matches provider API patterns (webhook-based)
- Good, because supports long-running verifications (document review)
- Good, because provider abstraction enables multi-provider routing
- Good, because sync fallback available for instant checks
- Bad, because requires webhook infrastructure
- Bad, because async flows increase system complexity
All verification calls block until completion with timeout.
- Good, because simpler request-response flow
- Good, because immediate verification result
- Bad, because provider timeouts (30s+) block user experience
- Bad, because cannot support manual review workflows
- Bad, because poor resilience when providers are slow
Full event sourcing with verification events and projections.
- Good, because complete audit trail via event log
- Good, because supports complex multi-step verification
- Bad, because significant infrastructure complexity
- Bad, because overkill for current verification requirements
- Bad, because steep learning curve for team
Delegate verification orchestration to specialized platform.
- Good, because pre-built provider integrations
- Good, because compliance expertise built-in
- Bad, because vendor lock-in
- Bad, because additional cost layer
- Bad, because less control over verification flow
┌─────────────────────────────────────────────────────────────────────────┐
│ Party Directory Service │
│ (BIAN Party Directory domain owner) │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Verification Orchestrator │
│ ┌─────────────────┐ ┌──────────────────┐ ┌────────────────────────┐ │
│ │ Request Router │ │ Status Tracker │ │ Result Aggregator │ │
│ │ (provider │ │ (verification │ │ (combine multi- │ │
│ │ selection) │ │ state machine) │ │ provider results) │ │
│ └─────────────────┘ └──────────────────┘ └────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Provider Abstraction Layer │
│ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ VerificationProvider Interface │ │
│ │ - InitiateVerification(request) -> VerificationReference │ │
│ │ - CheckStatus(reference) -> VerificationStatus │ │
│ │ - ParseWebhook(payload, signature) -> VerificationResult │ │
│ │ - GetSupportedChecks() -> []CheckType │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ ▲ ▲ ▲ │
│ │ │ │ │
│ ┌───────┴───────┐ ┌───────┴───────┐ ┌───────┴───────┐ │
│ │ Onfido │ │ Jumio │ │ ComplyAdvantage│ │
│ │ Adapter │ │ Adapter │ │ Adapter │ │
│ └───────────────┘ └───────────────┘ └───────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Webhook Handler │
│ ┌─────────────────┐ ┌──────────────────┐ ┌────────────────────────┐ │
│ │ Signature │ │ Idempotency │ │ Event Publisher │ │
│ │ Validator │ │ Guard │ │ (Kafka) │ │
│ └─────────────────┘ └──────────────────┘ └────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
Use synchronous verification for checks that:
- Complete within 5 seconds (configurable timeout)
- Are required before proceeding (hard blockers)
- Have high availability SLAs from providers
| Check Type | Typical Latency | Flow |
|---|---|---|
| Sanctions screening | 100-500ms | Synchronous |
| PEP (Politically Exposed Persons) | 200-800ms | Synchronous |
| Watchlist screening | 100-300ms | Synchronous |
// Synchronous verification - blocks until result
func (s *VerificationService) ScreenSanctions(ctx context.Context, req *SanctionsRequest) (*SanctionsResult, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
provider := s.router.SelectProvider(CheckTypeSanctions, req.Jurisdiction)
return provider.ScreenSanctions(ctx, req)
}Use asynchronous verification for checks that:
- May require manual review
- Have unpredictable latency (seconds to days)
- Can proceed with provisional status
| Check Type | Typical Latency | Flow |
|---|---|---|
| Document verification | 1-10 minutes | Async |
| Identity verification (selfie) | 30 seconds - 5 minutes | Async |
| Address verification | 1-24 hours | Async |
| Manual review escalation | 1-72 hours | Async |
| Enhanced due diligence | Days to weeks | Async |
// Asynchronous verification - returns reference immediately
func (s *VerificationService) InitiateIdentityVerification(
ctx context.Context,
req *IdentityVerificationRequest,
) (*VerificationReference, error) {
provider := s.router.SelectProvider(CheckTypeIdentity, req.Jurisdiction)
ref, err := provider.InitiateVerification(ctx, req)
if err != nil {
return nil, err
}
// Store pending verification
verification := &Verification{
ID: uuid.New(),
PartyID: req.PartyID,
ProviderRef: ref.ProviderReference,
Provider: provider.Name(),
CheckType: CheckTypeIdentity,
Status: VerificationStatusPending,
InitiatedAt: time.Now(),
}
if err := s.repo.Save(ctx, verification); err != nil {
return nil, err
}
// Publish event for downstream consumers
s.publisher.PublishVerificationInitiated(ctx, verification)
return &VerificationReference{
VerificationID: verification.ID,
ProviderRef: ref.ProviderReference,
CheckURL: ref.CheckURL, // URL for user to complete verification
}, nil
}type ProviderRouter struct {
providers map[string]VerificationProvider
rules []RoutingRule
fallbacks map[string][]string // provider -> fallback providers
}
type RoutingRule struct {
CheckType CheckType
Jurisdiction string
RiskLevel RiskLevel
Provider string
Priority int
}
func (r *ProviderRouter) SelectProvider(
checkType CheckType,
jurisdiction string,
riskLevel RiskLevel,
) (VerificationProvider, error) {
// Find matching rules sorted by priority
candidates := r.findMatchingRules(checkType, jurisdiction, riskLevel)
for _, rule := range candidates {
provider := r.providers[rule.Provider]
if provider.IsHealthy() {
return provider, nil
}
}
return nil, ErrNoAvailableProvider
}func (r *ProviderRouter) ExecuteWithFailover(
ctx context.Context,
checkType CheckType,
jurisdiction string,
fn func(VerificationProvider) error,
) error {
primaryProvider, _ := r.SelectProvider(checkType, jurisdiction, RiskLevelNormal)
err := fn(primaryProvider)
if err == nil {
return nil
}
// Try fallback providers
fallbacks := r.fallbacks[primaryProvider.Name()]
for _, fallbackName := range fallbacks {
fallback := r.providers[fallbackName]
if fallback.IsHealthy() {
if err := fn(fallback); err == nil {
return nil
}
}
}
return fmt.Errorf("all providers failed for %s: %w", checkType, err)
}verification:
providers:
onfido:
api_key: ${ONFIDO_API_KEY}
webhook_secret: ${ONFIDO_WEBHOOK_SECRET}
supported_checks: [identity, document, address]
jurisdictions: [GB, EU]
jumio:
api_key: ${JUMIO_API_KEY}
webhook_secret: ${JUMIO_WEBHOOK_SECRET}
supported_checks: [identity, document]
jurisdictions: [US, CA]
complyadvantage:
api_key: ${COMPLYADVANTAGE_API_KEY}
webhook_secret: ${COMPLYADVANTAGE_WEBHOOK_SECRET}
supported_checks: [sanctions, pep, watchlist]
jurisdictions: [GLOBAL]
routing_rules:
- check_type: identity
jurisdiction: GB
provider: onfido
priority: 1
- check_type: identity
jurisdiction: GB
provider: jumio
priority: 2 # Fallback
- check_type: sanctions
jurisdiction: "*"
provider: complyadvantage
priority: 1| Data Type | Retention Period | Storage | Encryption |
|---|---|---|---|
| Verification result (pass/fail) | 7 years | Database | At-rest (AES-256) |
| Provider reference ID | 7 years | Database | At-rest |
| Risk score | 7 years | Database | At-rest |
| Document images | Not stored | Provider only | N/A |
| Selfie images | Not stored | Provider only | N/A |
| Raw PII (DOB, address) | Not stored | Provider only | N/A |
| Verification audit log | 7 years | Audit service | At-rest |
type VerificationRecord struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey"`
PartyID uuid.UUID `gorm:"type:uuid;not null;index"`
ProviderReference string `gorm:"size:255;not null"` // Provider's ID
Provider string `gorm:"size:50;not null"`
CheckType CheckType `gorm:"size:50;not null"`
Status VerificationStatus `gorm:"size:50;not null"`
RiskScore *int `gorm:""` // 0-100, nullable
RiskLevel *RiskLevel `gorm:"size:20"` // LOW, MEDIUM, HIGH
ResultSummary string `gorm:"size:500"` // "Document verified", "Sanctions match found"
FailureReason *string `gorm:"size:500"` // If failed
InitiatedAt time.Time `gorm:"not null"`
CompletedAt *time.Time `gorm:""`
ExpiresAt *time.Time `gorm:"index"` // When verification expires
CreatedAt time.Time `gorm:"not null"`
UpdatedAt time.Time `gorm:"not null"`
}- Raw document images (passports, driving licenses)
- Selfie/biometric images
- Full address details
- Date of birth (beyond age verification result)
- Social security numbers / national ID numbers
- Bank statements or proof of address documents
// Right to Access - Return verification history without raw PII
func (s *VerificationService) GetVerificationHistory(
ctx context.Context,
partyID uuid.UUID,
) ([]*VerificationSummary, error) {
records, err := s.repo.FindByPartyID(ctx, partyID)
if err != nil {
return nil, err
}
summaries := make([]*VerificationSummary, len(records))
for i, r := range records {
summaries[i] = &VerificationSummary{
CheckType: r.CheckType,
Status: r.Status,
ResultSummary: r.ResultSummary,
CompletedAt: r.CompletedAt,
// No PII included
}
}
return summaries, nil
}
// Right to Erasure - Delete verification records (after retention period)
func (s *VerificationService) DeletePartyVerifications(
ctx context.Context,
partyID uuid.UUID,
) error {
// Verify retention period has passed
records, err := s.repo.FindByPartyID(ctx, partyID)
if err != nil {
return err
}
for _, r := range records {
if !r.RetentionPeriodExpired() {
return fmt.Errorf("retention period not expired for verification %s", r.ID)
}
}
// Also request deletion from provider
for _, r := range records {
provider := s.router.GetProvider(r.Provider)
if err := provider.RequestDataDeletion(ctx, r.ProviderReference); err != nil {
logger.Warn("Failed to delete from provider", "provider", r.Provider, "error", err)
// Continue - provider deletion is best-effort
}
}
return s.repo.DeleteByPartyID(ctx, partyID)
}All verification data encrypted using AES-256-GCM:
type EncryptedVerificationStore struct {
repo VerificationRepository
encryptor Encryptor
}
func (s *EncryptedVerificationStore) Save(ctx context.Context, v *Verification) error {
// Encrypt sensitive fields before storage
encrypted := &VerificationEntity{
ID: v.ID,
PartyID: v.PartyID,
Provider: v.Provider,
ProviderReference: s.encryptor.Encrypt(v.ProviderReference), // Encrypted
Status: v.Status,
ResultSummary: s.encryptor.Encrypt(v.ResultSummary), // Encrypted
// ... other fields
}
return s.repo.Save(ctx, encrypted)
}// Request only necessary data from provider
type IdentityVerificationRequest struct {
PartyID uuid.UUID
// Minimal PII - only what's required for verification
FirstName string // Required for name matching
LastName string // Required for name matching
CountryOfIssue string // Required for document routing
DocumentType string // passport, driving_license, national_id
// NOT included: full address, phone, email, SSN
}
// Provider handles PII collection directly
type VerificationReference struct {
VerificationID uuid.UUID
ProviderRef string
CheckURL string // User completes verification directly with provider
ExpiresAt time.Time
}func (s *VerificationService) GetVerificationDetails(
ctx context.Context,
verificationID uuid.UUID,
) (*VerificationDetails, error) {
// Log access for audit trail
s.auditLogger.LogAccess(ctx, AuditEvent{
Action: "verification.view",
ResourceType: "verification",
ResourceID: verificationID.String(),
Actor: GetActorFromContext(ctx),
Reason: GetAccessReasonFromContext(ctx),
Timestamp: time.Now(),
})
return s.repo.FindByID(ctx, verificationID)
}type WebhookHandler struct {
providers map[string]VerificationProvider
idempotencyStore IdempotencyStore
publisher EventPublisher
}
func (h *WebhookHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
provider := r.URL.Query().Get("provider")
if provider == "" {
http.Error(w, "missing provider", http.StatusBadRequest)
return
}
// 1. Validate signature
verifier, ok := h.providers[provider]
if !ok {
http.Error(w, "unknown provider", http.StatusBadRequest)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "failed to read body", http.StatusBadRequest)
return
}
signature := r.Header.Get("X-Webhook-Signature")
if !verifier.ValidateSignature(body, signature) {
logger.Warn("Invalid webhook signature", "provider", provider)
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
// 2. Parse webhook payload
result, err := verifier.ParseWebhook(body)
if err != nil {
http.Error(w, "failed to parse webhook", http.StatusBadRequest)
return
}
// 3. Idempotency check
idempotencyKey := fmt.Sprintf("%s:%s", provider, result.EventID)
if h.idempotencyStore.HasProcessed(r.Context(), idempotencyKey) {
// Already processed - return success (idempotent)
w.WriteHeader(http.StatusOK)
return
}
// 4. Process webhook
if err := h.processWebhook(r.Context(), provider, result); err != nil {
logger.Error("Failed to process webhook", "error", err)
http.Error(w, "processing failed", http.StatusInternalServerError)
return
}
// 5. Mark as processed
h.idempotencyStore.MarkProcessed(r.Context(), idempotencyKey, 24*time.Hour)
w.WriteHeader(http.StatusOK)
}type OnfidoProvider struct {
webhookSecret []byte
}
func (p *OnfidoProvider) ValidateSignature(payload []byte, signature string) bool {
mac := hmac.New(sha256.New, p.webhookSecret)
mac.Write(payload)
expectedMAC := mac.Sum(nil)
expectedSignature := hex.EncodeToString(expectedMAC)
return hmac.Equal([]byte(signature), []byte(expectedSignature))
}Webhook endpoints must be idempotent because providers retry on failure:
type IdempotencyStore interface {
HasProcessed(ctx context.Context, key string) bool
MarkProcessed(ctx context.Context, key string, ttl time.Duration) error
}
type RedisIdempotencyStore struct {
client *redis.Client
}
func (s *RedisIdempotencyStore) HasProcessed(ctx context.Context, key string) bool {
exists, _ := s.client.Exists(ctx, "webhook:processed:"+key).Result()
return exists > 0
}
func (s *RedisIdempotencyStore) MarkProcessed(ctx context.Context, key string, ttl time.Duration) error {
return s.client.Set(ctx, "webhook:processed:"+key, "1", ttl).Err()
}func (h *WebhookHandler) processWebhook(
ctx context.Context,
provider string,
result *WebhookResult,
) error {
// Find verification by provider reference
verification, err := h.repo.FindByProviderReference(ctx, provider, result.Reference)
if err != nil {
return fmt.Errorf("verification not found: %w", err)
}
// Update verification status
verification.Status = result.Status
verification.RiskScore = result.RiskScore
verification.RiskLevel = result.RiskLevel
verification.ResultSummary = result.Summary
verification.CompletedAt = &result.CompletedAt
if result.Status == VerificationStatusFailed {
verification.FailureReason = &result.FailureReason
}
if err := h.repo.Update(ctx, verification); err != nil {
return fmt.Errorf("failed to update verification: %w", err)
}
// Publish domain event
event := &VerificationCompleted{
VerificationID: verification.ID,
PartyID: verification.PartyID,
CheckType: verification.CheckType,
Status: verification.Status,
RiskLevel: verification.RiskLevel,
CompletedAt: *verification.CompletedAt,
}
return h.publisher.Publish(ctx, "verification.completed", event)
}// VerificationProvider defines the contract for all KYC/AML providers
type VerificationProvider interface {
// Provider identification
Name() string
SupportedChecks() []CheckType
SupportedJurisdictions() []string
// Health check
IsHealthy() bool
HealthCheck(ctx context.Context) error
// Synchronous operations
ScreenSanctions(ctx context.Context, req *SanctionsRequest) (*SanctionsResult, error)
ScreenPEP(ctx context.Context, req *PEPRequest) (*PEPResult, error)
// Asynchronous operations
InitiateVerification(ctx context.Context, req *VerificationRequest) (*VerificationReference, error)
CheckStatus(ctx context.Context, reference string) (*VerificationStatus, error)
// Webhook handling
ValidateSignature(payload []byte, signature string) bool
ParseWebhook(payload []byte) (*WebhookResult, error)
// GDPR compliance
RequestDataDeletion(ctx context.Context, reference string) error
}- Create adapter implementing interface:
// internal/adapters/verification/persona_provider.go
type PersonaProvider struct {
client *persona.Client
webhookSecret []byte
config PersonaConfig
}
var _ VerificationProvider = (*PersonaProvider)(nil) // Compile-time check
func NewPersonaProvider(config PersonaConfig) *PersonaProvider {
return &PersonaProvider{
client: persona.NewClient(config.APIKey),
webhookSecret: []byte(config.WebhookSecret),
config: config,
}
}
func (p *PersonaProvider) Name() string {
return "persona"
}
func (p *PersonaProvider) SupportedChecks() []CheckType {
return []CheckType{CheckTypeIdentity, CheckTypeDocument}
}
func (p *PersonaProvider) SupportedJurisdictions() []string {
return []string{"US", "CA"}
}
// Implement remaining interface methods...- Register in provider factory:
func NewProviderRouter(config *Config) *ProviderRouter {
router := &ProviderRouter{
providers: make(map[string]VerificationProvider),
}
// Register providers based on configuration
if config.Onfido.Enabled {
router.providers["onfido"] = NewOnfidoProvider(config.Onfido)
}
if config.Jumio.Enabled {
router.providers["jumio"] = NewJumioProvider(config.Jumio)
}
if config.Persona.Enabled {
router.providers["persona"] = NewPersonaProvider(config.Persona)
}
if config.ComplyAdvantage.Enabled {
router.providers["complyadvantage"] = NewComplyAdvantageProvider(config.ComplyAdvantage)
}
router.loadRoutingRules(config.RoutingRules)
return router
}- Add webhook route:
func SetupWebhookRoutes(router *mux.Router, handler *WebhookHandler) {
// Single endpoint with provider query param
router.HandleFunc("/webhooks/verification", handler.HandleWebhook).
Methods("POST").
Queries("provider", "{provider}")
// Or provider-specific endpoints
router.HandleFunc("/webhooks/onfido", handler.HandleOnfidoWebhook).Methods("POST")
router.HandleFunc("/webhooks/jumio", handler.HandleJumioWebhook).Methods("POST")
router.HandleFunc("/webhooks/persona", handler.HandlePersonaWebhook).Methods("POST")
}func TestPersonaProvider_ImplementsInterface(t *testing.T) {
// Verify all interface methods are implemented
var _ VerificationProvider = (*PersonaProvider)(nil)
}
func TestPersonaProvider_InitiateVerification(t *testing.T) {
// Use provider's sandbox/test environment
provider := NewPersonaProvider(PersonaConfig{
APIKey: os.Getenv("PERSONA_TEST_API_KEY"),
Sandbox: true,
})
ref, err := provider.InitiateVerification(context.Background(), &VerificationRequest{
FirstName: "Test",
LastName: "User",
CountryOfIssue: "US",
DocumentType: "driving_license",
})
require.NoError(t, err)
assert.NotEmpty(t, ref.ProviderReference)
assert.NotEmpty(t, ref.CheckURL)
}
func TestPersonaProvider_WebhookSignature(t *testing.T) {
provider := NewPersonaProvider(PersonaConfig{
WebhookSecret: "test-secret",
})
payload := []byte(`{"event_type":"verification.completed"}`)
// Create valid signature
mac := hmac.New(sha256.New, []byte("test-secret"))
mac.Write(payload)
validSignature := hex.EncodeToString(mac.Sum(nil))
assert.True(t, provider.ValidateSignature(payload, validSignature))
assert.False(t, provider.ValidateSignature(payload, "invalid-signature"))
} ┌──────────────┐
│ PENDING │
└──────────────┘
│
┌────────────┼────────────┐
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌──────────┐ ┌──────────────┐
│ IN_PROGRESS │ │ FAILED │ │ EXPIRED │
└──────────────┘ └──────────┘ └──────────────┘
│ ▲
│ │
▼ │
┌──────────────┐ │
│ REVIEW │──────┘
└──────────────┘
│
│
▼
┌──────────────┐
│ APPROVED │
└──────────────┘
- ADR-002: Microservices per BIAN Domain
- ADR-004: Event Schema Evolution
- ADR-005: Adapter Pattern for Layer Translation
- ADR-009: Application-Level Audit Logging
- ADR-019: Resilient Client Patterns
- BIAN Party Directory Service Domain
- GDPR Article 17 - Right to Erasure
- PCI DSS Requirements for PII
- Biometric liveness detection: As deepfake technology improves, consider providers with advanced liveness detection
- Continuous monitoring: Move from point-in-time verification to ongoing sanctions monitoring
- Self-sovereign identity: SSI/DID integration when ecosystem matures
- Provider SLA monitoring: Automated provider health scoring based on latency and accuracy
Existing identity verification implementations should migrate to this architecture:
- Implement provider interface for existing provider
- Add routing rules
- Migrate existing verification records to new schema
- Deprecate direct provider calls
- Remove legacy implementation
- Webhook endpoint availability: Must have 99.9%+ uptime (providers retry, but queue eventually)
- Monitoring: Alert on webhook processing latency > 5s, failure rate > 1%
- Rate limiting: Implement rate limiting on webhook endpoints (100 req/s per provider)
- Circuit breaker: Apply ADR-019 patterns to provider calls