# Webhooks ## Overview Release Orchestrator supports both inbound webhooks (receiving events from external systems) and outbound webhooks (sending events to external systems). ## Inbound Webhooks ### Webhook Types | Type | Source | Triggers | |------|--------|----------| | `registry-push` | Container registries | Image push events | | `ci-pipeline` | CI/CD systems | Pipeline completion | | `github-app` | GitHub | PR, push, workflow events | | `gitlab-webhook` | GitLab | Pipeline, push, MR events | | `generic` | Any system | Custom payloads | ### Registry Push Webhook Receives events when new images are pushed to registries. ``` POST /api/v1/webhooks/registry/{integrationId} Content-Type: application/json # Docker Hub { "push_data": { "tag": "v1.2.0", "images": ["sha256:abc123..."], "pushed_at": 1704067200 }, "repository": { "name": "myapp", "namespace": "myorg", "repo_url": "https://hub.docker.com/r/myorg/myapp" } } # Harbor { "type": "PUSH_ARTIFACT", "occur_at": 1704067200, "event_data": { "repository": { "name": "myapp", "repo_full_name": "myorg/myapp" }, "resources": [{ "digest": "sha256:abc123...", "tag": "v1.2.0" }] } } ``` ### Webhook Handler ```typescript interface WebhookHandler { handleRegistryPush( integrationId: UUID, payload: RegistryPushPayload ): Promise; handleCIPipeline( integrationId: UUID, payload: CIPipelinePayload ): Promise; } class RegistryWebhookHandler implements WebhookHandler { async handleRegistryPush( integrationId: UUID, payload: RegistryPushPayload ): Promise { // Normalize payload from different registries const normalized = this.normalizePayload(payload); // Find matching component const component = await this.componentRegistry.findByRepository( normalized.repository ); if (!component) { return { success: true, action: "ignored", reason: "No matching component" }; } // Update version map await this.versionManager.addVersion({ componentId: component.id, tag: normalized.tag, digest: normalized.digest, channel: this.determineChannel(normalized.tag) }); // Check for auto-release triggers const triggers = await this.getTriggers(component.id, normalized.tag); for (const trigger of triggers) { await this.triggerRelease(trigger, normalized); } return { success: true, action: "processed", componentId: component.id, versionsAdded: 1, triggersActivated: triggers.length }; } private normalizePayload(payload: any): NormalizedPushEvent { // Detect registry type and normalize if (payload.push_data) { // Docker Hub format return { repository: `${payload.repository.namespace}/${payload.repository.name}`, tag: payload.push_data.tag, digest: payload.push_data.images[0], pushedAt: new Date(payload.push_data.pushed_at * 1000) }; } if (payload.type === "PUSH_ARTIFACT") { // Harbor format return { repository: payload.event_data.repository.repo_full_name, tag: payload.event_data.resources[0].tag, digest: payload.event_data.resources[0].digest, pushedAt: new Date(payload.occur_at * 1000) }; } // Generic format return payload as NormalizedPushEvent; } } ``` ### Webhook Authentication ```typescript interface WebhookAuth { // Signature validation validateSignature( payload: Buffer, signature: string, secret: string, algorithm: SignatureAlgorithm ): boolean; // Token validation validateToken( token: string, expectedToken: string ): boolean; } type SignatureAlgorithm = "hmac-sha256" | "hmac-sha1"; class WebhookAuthenticator implements WebhookAuth { validateSignature( payload: Buffer, signature: string, secret: string, algorithm: SignatureAlgorithm ): boolean { const algo = algorithm === "hmac-sha256" ? "sha256" : "sha1"; const expected = crypto .createHmac(algo, secret) .update(payload) .digest("hex"); // Constant-time comparison return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expected) ); } } ``` ### Webhook Configuration ```typescript interface WebhookConfig { id: UUID; integrationId: UUID; type: WebhookType; // Security secretRef: string; // Vault reference for signature secret signatureHeader?: string; // Header containing signature signatureAlgorithm?: SignatureAlgorithm; // Processing enabled: boolean; filters?: WebhookFilter[]; // Filter events // Retry retryPolicy: RetryPolicy; } interface WebhookFilter { field: string; // JSONPath to field operator: "equals" | "contains" | "matches"; value: string; } // Example: Only process tags matching semver const semverFilter: WebhookFilter = { field: "$.tag", operator: "matches", value: "^v\\d+\\.\\d+\\.\\d+$" }; ``` ## Outbound Webhooks ### Event Types | Event | Description | Payload | |-------|-------------|---------| | `release.created` | New release created | Release details | | `promotion.requested` | Promotion requested | Promotion details | | `promotion.approved` | Promotion approved | Approval details | | `promotion.rejected` | Promotion rejected | Rejection details | | `deployment.started` | Deployment started | Job details | | `deployment.completed` | Deployment completed | Job details, results | | `deployment.failed` | Deployment failed | Job details, error | | `rollback.initiated` | Rollback initiated | Rollback details | ### Webhook Subscription ```typescript interface WebhookSubscription { id: UUID; tenantId: UUID; name: string; // Target url: string; method: "POST" | "PUT"; headers?: Record; // Authentication authType: "none" | "basic" | "bearer" | "signature"; credentialRef?: string; signatureSecret?: string; // Events events: string[]; // Event types to subscribe filters?: EventFilter[]; // Filter events // Delivery retryPolicy: RetryPolicy; timeout: number; // Status enabled: boolean; lastDelivery?: DateTime; lastStatus?: number; } interface EventFilter { field: string; operator: string; value: any; } ``` ### Webhook Delivery ```typescript interface WebhookPayload { id: string; // Delivery ID timestamp: string; // ISO-8601 event: string; // Event type tenantId: string; data: Record; // Event-specific data } class WebhookDeliveryService { async deliver( subscription: WebhookSubscription, event: DomainEvent ): Promise { const payload: WebhookPayload = { id: uuidv4(), timestamp: new Date().toISOString(), event: event.type, tenantId: subscription.tenantId, data: this.buildEventData(event) }; const headers = this.buildHeaders(subscription, payload); const body = JSON.stringify(payload); // Attempt delivery with retries return this.deliverWithRetry(subscription, headers, body); } private buildHeaders( subscription: WebhookSubscription, payload: WebhookPayload ): Record { const headers: Record = { "Content-Type": "application/json", "X-Stella-Event": payload.event, "X-Stella-Delivery": payload.id, "X-Stella-Timestamp": payload.timestamp, ...subscription.headers }; // Add signature if configured if (subscription.authType === "signature") { const signature = this.computeSignature( JSON.stringify(payload), subscription.signatureSecret! ); headers["X-Stella-Signature"] = signature; } return headers; } private async deliverWithRetry( subscription: WebhookSubscription, headers: Record, body: string ): Promise { const policy = subscription.retryPolicy; let lastError: Error | undefined; for (let attempt = 0; attempt <= policy.maxRetries; attempt++) { try { const response = await fetch(subscription.url, { method: subscription.method, headers, body, signal: AbortSignal.timeout(subscription.timeout) }); // Record delivery await this.recordDelivery(subscription.id, { attempt, statusCode: response.status, success: response.ok }); if (response.ok) { return { success: true, statusCode: response.status, attempts: attempt + 1 }; } // Non-retryable status codes if (response.status >= 400 && response.status < 500) { return { success: false, statusCode: response.status, attempts: attempt + 1, error: `Client error: ${response.status}` }; } lastError = new Error(`Server error: ${response.status}`); } catch (error) { lastError = error as Error; } // Wait before retry if (attempt < policy.maxRetries) { const delay = this.calculateDelay(policy, attempt); await sleep(delay); } } return { success: false, attempts: policy.maxRetries + 1, error: lastError?.message }; } } ``` ### Delivery Logging ```typescript interface WebhookDeliveryLog { id: UUID; subscriptionId: UUID; deliveryId: string; // Request url: string; method: string; headers: Record; body: string; // Response statusCode?: number; responseBody?: string; responseTime: number; // Result success: boolean; attempt: number; error?: string; // Timing createdAt: DateTime; } ``` ## Webhook API ### Register Subscription ```http POST /api/v1/webhook-subscriptions Content-Type: application/json { "name": "Deployment Notifications", "url": "https://api.example.com/webhooks/stella", "method": "POST", "authType": "signature", "signatureSecret": "my-secret-key", "events": [ "deployment.started", "deployment.completed", "deployment.failed" ], "filters": [ { "field": "data.environment.name", "operator": "equals", "value": "production" } ], "retryPolicy": { "maxRetries": 3, "backoffType": "exponential", "backoffSeconds": 10 }, "timeout": 30000 } ``` ### Test Subscription ```http POST /api/v1/webhook-subscriptions/{id}/test Content-Type: application/json { "event": "deployment.completed" } ``` Response: ```json { "success": true, "data": { "deliveryId": "d1234567-...", "statusCode": 200, "responseTime": 245, "response": "OK" } } ``` ### List Deliveries ```http GET /api/v1/webhook-subscriptions/{id}/deliveries?page=1&pageSize=20 ``` ## Event Payloads ### deployment.completed ```json { "id": "delivery-uuid", "timestamp": "2026-01-09T10:30:00Z", "event": "deployment.completed", "tenantId": "tenant-uuid", "data": { "deploymentJob": { "id": "job-uuid", "status": "completed" }, "release": { "id": "release-uuid", "name": "myapp-v1.2.0", "components": [ { "name": "api", "digest": "sha256:abc123..." } ] }, "environment": { "id": "env-uuid", "name": "production" }, "promotion": { "id": "promo-uuid", "requestedBy": "user@example.com" }, "targets": [ { "id": "target-uuid", "name": "prod-host-1", "status": "succeeded" } ], "timing": { "startedAt": "2026-01-09T10:25:00Z", "completedAt": "2026-01-09T10:30:00Z", "durationSeconds": 300 } } } ``` ### promotion.requested ```json { "id": "delivery-uuid", "timestamp": "2026-01-09T10:00:00Z", "event": "promotion.requested", "tenantId": "tenant-uuid", "data": { "promotion": { "id": "promo-uuid", "status": "pending_approval" }, "release": { "id": "release-uuid", "name": "myapp-v1.2.0" }, "sourceEnvironment": { "id": "staging-uuid", "name": "staging" }, "targetEnvironment": { "id": "prod-uuid", "name": "production" }, "requestedBy": { "id": "user-uuid", "email": "user@example.com", "name": "John Doe" }, "approvalRequired": { "count": 2, "currentApprovals": 0 } } } ``` ## Security Considerations ### Signature Verification Receivers should verify webhook signatures: ```python import hmac import hashlib def verify_signature(payload: bytes, signature: str, secret: str) -> bool: expected = hmac.new( secret.encode(), payload, hashlib.sha256 ).hexdigest() return hmac.compare_digest(signature, expected) # In webhook handler @app.route("/webhooks/stella", methods=["POST"]) def handle_webhook(): signature = request.headers.get("X-Stella-Signature") if not verify_signature(request.data, signature, WEBHOOK_SECRET): return "Invalid signature", 401 payload = request.json # Process event... ``` ### IP Allowlisting Configure firewall rules to only accept webhooks from Stella IP ranges: - Document IP ranges in deployment configuration - Use VPN or private networking where possible ### Replay Protection Check delivery timestamps to prevent replay attacks: ```python from datetime import datetime, timedelta MAX_TIMESTAMP_AGE = timedelta(minutes=5) def check_timestamp(timestamp_str: str) -> bool: timestamp = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00")) now = datetime.now(timestamp.tzinfo) return abs(now - timestamp) < MAX_TIMESTAMP_AGE ``` ## References - [Integrations Overview](overview.md) - [Connectors](connectors.md) - [CI/CD Integration](ci-cd.md)