Files

14 KiB

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

interface WebhookHandler {
  handleRegistryPush(
    integrationId: UUID,
    payload: RegistryPushPayload
  ): Promise<WebhookResponse>;

  handleCIPipeline(
    integrationId: UUID,
    payload: CIPipelinePayload
  ): Promise<WebhookResponse>;
}

class RegistryWebhookHandler implements WebhookHandler {
  async handleRegistryPush(
    integrationId: UUID,
    payload: RegistryPushPayload
  ): Promise<WebhookResponse> {
    // 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

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

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

interface WebhookSubscription {
  id: UUID;
  tenantId: UUID;
  name: string;

  // Target
  url: string;
  method: "POST" | "PUT";
  headers?: Record<string, string>;

  // 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

interface WebhookPayload {
  id: string;                  // Delivery ID
  timestamp: string;           // ISO-8601
  event: string;               // Event type
  tenantId: string;
  data: Record<string, any>;   // Event-specific data
}

class WebhookDeliveryService {
  async deliver(
    subscription: WebhookSubscription,
    event: DomainEvent
  ): Promise<DeliveryResult> {
    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<string, string> {
    const headers: Record<string, string> = {
      "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<string, string>,
    body: string
  ): Promise<DeliveryResult> {
    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

interface WebhookDeliveryLog {
  id: UUID;
  subscriptionId: UUID;
  deliveryId: string;

  // Request
  url: string;
  method: string;
  headers: Record<string, string>;
  body: string;

  // Response
  statusCode?: number;
  responseBody?: string;
  responseTime: number;

  // Result
  success: boolean;
  attempt: number;
  error?: string;

  // Timing
  createdAt: DateTime;
}

Webhook API

Register Subscription

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

POST /api/v1/webhook-subscriptions/{id}/test
Content-Type: application/json

{
  "event": "deployment.completed"
}

Response:

{
  "success": true,
  "data": {
    "deliveryId": "d1234567-...",
    "statusCode": 200,
    "responseTime": 245,
    "response": "OK"
  }
}

List Deliveries

GET /api/v1/webhook-subscriptions/{id}/deliveries?page=1&pageSize=20

Event Payloads

deployment.completed

{
  "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

{
  "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:

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:

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