How We Added Generic Webhooks to SendRec

Published: (February 21, 2026 at 12:21 PM EST)
5 min read
Source: Dev.to

Source: Dev.to

Webhook Overview

SendRec already supports Slack notifications, but Slack is just one channel.
If you need to trigger an n8n workflow when someone views a video, POST to your CRM when a viewer clicks a 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.

User Configuration

  • 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..."
  }
}

Event Types

Seven event types cover the full video lifecycle:

EventDescription
video.createdA new video record is created
video.readyThe video has finished processing and is ready to watch
video.deletedThe video has been removed
video.viewedA viewer watched the video
video.commentA comment was added
video.milestoneA view‑count milestone was reached
video.cta_clickA call‑to‑action button was clicked

Payload Signing

Each request includes an X-Webhook-Signature header so the receiver can verify that the payload hasn’t been tampered with.
We use HMAC‑SHA256 and follow the same convention as GitHub webhooks (sha256= prefix).

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 the first time a webhook URL is saved – 32 random bytes, hex‑encoded.
  • The receiver recomputes the HMAC over the request body and compares it to the header value.
  • The sha256= prefix makes the algorithm explicit and leaves room for future algorithms without breaking existing integrations.

Retry & Delivery Logic

Webhook endpoints can go down, deployments restart, or load balancers hiccup. A single failed delivery must not mean a lost event.

Retry Policy

  • Up to 3 attempts with exponential back‑off (1s, then 4s).
  • Configurable delays (c.retryDelays).
  • Context cancellation aborts the wait, so shutdowns don’t block.
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 = 200 && *statusCode  maxResponseBodyBytes {
        respBody = respBody[:maxResponseBodyBytes]
    }
}

We read one byte more than the limit to detect truncation, then trim to exactly 1024 bytes.


Dispatch Helper (Fire‑and‑Forget)

The helper fires a webhook in a background goroutine, mirroring the existing Slack‑notification pattern.

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)
    }()
}
  • For handlers that already run inside a goroutine (e.g., comment notifications, milestone recording, CTA click tracking), we call the webhook client directly—no extra goroutine is needed.

URL Validation & Secret Generation

  • HTTPS only, with one exception: http://localhost and http://127.0.0.1 are allowed for local development.
  • URL length is capped at 500 characters.
  • An empty URL clears the webhook (stored as NULL) – no separate toggle required, same pattern as Slack.

When a webhook URL is saved for the first time and no secret exists, we generate one automatically:

func generateWebhookSecret() (string, error) {
    b := make([]byte, 32) // 32 random bytes
    if _, err := rand.Read(b); err != nil {
        return "", fmt.Errorf("generate secret: %w", err)
    }
    return hex.EncodeToString(b), nil
}

The generated secret is then used for the X-Webhook-Signature header on every dispatch.


TL;DR

  • User‑configurable webhook URL → JSON payload per event.
  • HMAC‑SHA256 signing with X-Webhook-Signature.
  • Retry up to 3 times with exponential back‑off, full logging.
  • Delivery rows stored in webhook_deliveries (truncated response bodies).
  • Fire‑and‑forget dispatch that never blocks the user‑facing HTTP response.
  • HTTPS enforced (localhost exception) and auto‑generated secret for each user.

All of this gives SendRec a robust, generic webhook system that can integrate with any downstream service.

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

Updating the Webhook URL

When you update the webhook URL, the existing secret is preserved using 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 need a new secret, use the dedicated “Regenerate” button. This creates a fresh secret via a separate endpoint from the one that saves the URL, so you don’t have to copy a new secret each time you change the endpoint.


Settings → Webhook Section

  • URL input – field with a Save button.
  • Signing secret – masked by default, with Copy and Regenerate buttons.
  • Send test event – dispatches a webhook.test event so you can verify delivery without waiting for a real view.
  • Recent deliveries – shows the last 50 delivery attempts, each with:
    • Event type
    • Status badge
    • Timestamp
    • Expandable rows displaying the full payload and response body
  • Events reference – a collapsible table that lists 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 Out

SendRec is open‑source (AGPL‑3.0) and self‑hostable. Generic webhooks are live at app.sendrec.eu:

  1. Go to SettingsWebhooks.
  2. Add a webhook URL.
  3. Click Send test event.

The webhook client implementation can be found in webhook.go.

0 views
Back to Blog

Related posts

Read more »

How We Added Video Playlists to SendRec

Overview Folders group videos by project and tags cross‑label them, but neither solves the common request: “I want to send someone five videos in a specific or...