Portal Community

Webhook URL Lifecycle

OperationAPIEffect
CreatePOST /api/webhooksGenerates a new URL and secret
Get URLGET /api/webhooks/{id}Returns URL (secret never returned after creation)
Rotate SecretPOST /api/webhooks/{id}/rotateGenerates new secret; old secret valid for 24h grace period
DeactivatePATCH /api/webhooks/{id} (active: false)Stops accepting requests — returns 410 Gone
DeleteDELETE /api/webhooks/{id}Permanently removes URL and secret

Create Response

POST /api/webhooks
{
  "processId": "proc-orders-main",
  "threadId": "main",
  "description": "Order events from Shopify"
}

Response 201:
{
  "webhookId": "wh-abc123",
  "url": "https://api.bizfirstai.com/webhook/tenant-acme/proc-orders-main",
  "secret": "whs_sk_live_Xy9mQ3...",    ← shown ONCE, then hashed
  "createdAt": "2026-05-25T10:00:00Z"
}
Secret storage: The secret is shown only once at creation time. Store it securely immediately — the platform only stores a bcrypt hash. If lost, rotate to get a new secret.

Secret Rotation with Grace Period

When rotating a secret, the old secret remains valid for 24 hours. This allows you to update the secret in the external system without any downtime:

  1. Call POST /api/webhooks/{id}/rotate → receive new secret
  2. Update the secret in your external system (Shopify, GitHub, etc.)
  3. Old secret still accepted for 24 hours in case some requests are already in-flight
  4. After 24 hours, old secret is invalidated automatically

WebhookUrlService (Backend)

// ProcessEngine/Webhooks/WebhookUrlService.cs
public class WebhookUrlService
{
    public async Task<WebhookRegistration> CreateAsync(CreateWebhookRequest request)
    {
        var secret = _secretGenerator.Generate(32); // cryptographically random
        var credentialId = await _credentialResolver.StoreSecretAsync(secret);

        var registration = new WebhookRegistration
        {
            TenantId = request.TenantId,
            ProcessId = request.ProcessId,
            ThreadId = request.ThreadId,
            CredentialId = credentialId,
            IsActive = true
        };
        await _repo.CreateAsync(registration);

        return registration with { PlaintextSecret = secret }; // returned ONCE
    }
}