Try it Free

How We Added Generic Webhooks to SendRec

SendRec already had Slack notifications. But Slack is one channel. What if you want to trigger an n8n workflow when someone views your video? Or POST to your CRM when a viewer clicks your call-to-action? Or pipe every event into a custom dashboard?

You need generic webhooks — a single URL that receives every event as a JSON POST.

What we built

Every user can configure a webhook URL in Settings. When something happens to their videos, SendRec POSTs a JSON payload to that URL:

{
  "event": "video.viewed",
  "timestamp": "2026-02-21T14:30:00Z",
  "data": {
    "videoId": "abc-123",
    "title": "Product Demo",
    "watchUrl": "https://app.sendrec.eu/watch/xyz",
    "viewCount": 5,
    "viewerHash": "sha256..."
  }
}

Seven event types cover the full video lifecycle: video.created, video.ready, video.deleted, video.viewed, video.comment, video.milestone, and video.cta_click.

Each request includes an X-Webhook-Signature header so the receiver can verify the payload hasn’t been tampered with.

Signing payloads

We use HMAC-SHA256. The entire approach fits in five lines:

func SignPayload(secret string, payload []byte) string {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(payload)
    return "sha256=" + hex.EncodeToString(mac.Sum(nil))
}

The secret is auto-generated when you first save a webhook URL — 32 random bytes, hex-encoded. The signature goes into the X-Webhook-Signature header. On the receiving end, you compute the same HMAC over the request body and compare. If they match, the payload is authentic.

The sha256= prefix follows the same convention as GitHub webhooks. It makes the signing algorithm explicit and leaves room for future algorithms without breaking existing integrations.

Retries with exponential backoff

Webhook endpoints go down. Deployments restart. Load balancers hiccup. A single failed delivery shouldn’t mean a lost event.

We retry up to 3 times with exponential backoff (1s, then 4s):

func (c *Client) Dispatch(ctx context.Context, userID, webhookURL, secret string, event Event) error {
    body, err := json.Marshal(event)
    if err != nil {
        return fmt.Errorf("marshal webhook payload: %w", err)
    }

    signature := SignPayload(secret, body)
    maxAttempts := 1 + len(c.retryDelays)
    var lastErr error

    for attempt := 1; attempt <= maxAttempts; attempt++ {
        statusCode, respBody, err := c.doPost(ctx, webhookURL, body, signature)
        c.logDelivery(ctx, userID, event.Name, body, statusCode, respBody, attempt)

        if err == nil && statusCode != nil && *statusCode >= 200 && *statusCode < 300 {
            return nil
        }

        if err != nil {
            lastErr = err
        } else if statusCode != nil {
            lastErr = fmt.Errorf("webhook returned status %d", *statusCode)
        }

        if attempt < maxAttempts {
            select {
            case <-time.After(c.retryDelays[attempt-1]):
            case <-ctx.Done():
                return ctx.Err()
            }
        }
    }

    return lastErr
}

A few things worth noting:

  • Every attempt is logged, not just the final one. If the first attempt gets a 502 and the third succeeds, you see all three in the delivery log.
  • Context cancellation interrupts the retry wait. If the server is shutting down, we don’t block on a 4-second sleep.
  • Success is any 2xx status code. We don’t require exactly 200 — a 201 or 204 from your endpoint is fine.
  • The retry delays are configurable on the client struct. Tests set them to 1ms so the test suite stays fast.

The delivery log

Every delivery attempt gets a row in webhook_deliveries:

CREATE TABLE webhook_deliveries (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id),
    event TEXT NOT NULL,
    payload JSONB NOT NULL,
    status_code INTEGER,
    response_body TEXT,
    attempt INTEGER NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

The status_code column is nullable. If the endpoint is unreachable (DNS failure, connection refused, timeout), there’s no status code to record — that’s a NULL. This makes it easy to distinguish “your server returned 500” from “we couldn’t reach your server at all.”

Response bodies are truncated to 1 KB. Enough to capture error messages, not enough to blow up storage if someone returns a 10 MB HTML error page.

respBytes, _ := io.ReadAll(io.LimitReader(resp.Body, int64(maxResponseBodyBytes)+1))
respBody := string(respBytes)
if len(respBody) > maxResponseBodyBytes {
    respBody = respBody[:maxResponseBodyBytes]
}

We read one byte more than the limit so we know whether truncation happened, then trim to exactly 1024 bytes.

Wiring events to trigger points

The dispatchWebhook helper fires a webhook in a background goroutine:

func (h *Handler) dispatchWebhook(userID string, event webhook.Event) {
    if h.webhookClient == nil {
        return
    }
    go func() {
        ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
        defer cancel()
        webhookURL, secret, err := h.webhookClient.LookupConfigByUserID(ctx, userID)
        if err != nil {
            return
        }
        _ = h.webhookClient.Dispatch(ctx, userID, webhookURL, secret, event)
    }()
}

This follows the same pattern as existing Slack notifications — fire and forget. The webhook dispatch never blocks the HTTP response to the user.

For handlers that already run inside a goroutine (comment notifications, milestone recording, CTA click tracking), we call the webhook client directly instead of spawning another goroutine. No need for goroutine-inside-goroutine.

URL validation

We enforce HTTPS for webhook URLs, with one exception: http://localhost and http://127.0.0.1 are allowed for local development. Self-hosters testing with a local n8n instance shouldn’t need to set up TLS for a loopback address.

The URL is capped at 500 characters. Empty URL clears the webhook (stored as NULL) — no separate toggle needed, same pattern as Slack.

Secret management

When you first save a webhook URL and no secret exists yet, we generate one automatically:

func generateWebhookSecret() (string, error) {
    b := make([]byte, 32)
    if _, err := rand.Read(b); err != nil {
        return "", err
    }
    return hex.EncodeToString(b), nil
}

On subsequent URL updates, the existing secret is preserved via COALESCE:

INSERT INTO notification_preferences (user_id, webhook_url, webhook_secret)
VALUES ($1, $2, $3)
ON CONFLICT (user_id) DO UPDATE SET
    webhook_url = $2,
    webhook_secret = COALESCE(notification_preferences.webhook_secret, $3)

If you want a new secret, there’s a dedicated “Regenerate” button that creates a fresh one. This is a separate endpoint from saving the URL — you shouldn’t have to copy a new secret every time you change your endpoint.

The Settings UI

The webhook section in Settings shows:

  1. URL input with Save button
  2. Signing secret — masked by default, with copy and regenerate buttons
  3. Send test event button — dispatches a webhook.test event so you can verify delivery without waiting for a real view
  4. Recent deliveries — the last 50 delivery attempts with event type, status badge, timestamp, and expandable rows showing the full payload and response body
  5. Events reference — a collapsible table listing all event types and their payload fields

The delivery log is the most useful part. When your webhook endpoint returns errors, you can see exactly what was sent and what came back, without digging through your server logs.

Try it

SendRec is open source (AGPL-3.0) and self-hostable. Generic webhooks are live at app.sendrec.eu — go to Settings, add a webhook URL, and send a test event. The webhook client implementation is in webhook.go.