DEV Community

Alex Neamtu
Alex Neamtu

Posted on • Originally published at sendrec.eu

How We Added Call-to-Action Buttons to Shared Videos

You record a product demo and send it to a prospect. They watch the whole thing. Then they close the tab.

The video did its job — they saw the feature, they understood the value — but there was no next step. No link to the pricing page. No "Book a demo" button. No way to convert attention into action.

We added per-video call-to-action buttons to SendRec. When a viewer watches a video to the end, a CTA card appears with a customizable button. Clicks are tracked and reported as a click-through rate in per-video analytics.

How it works

The CTA card is hidden during playback. When the video's ended event fires, it becomes visible:

var player = document.getElementById('player');
var ctaCard = document.getElementById('cta-card');
if (player && ctaCard) {
    player.addEventListener('ended', function() {
        ctaCard.classList.add('visible');
    });
}
Enter fullscreen mode Exit fullscreen mode

The card has two elements: a dismiss button and the CTA link. The dismiss button removes the visible class. The CTA link opens in a new tab and fires a tracking request in the background:

if (ctaBtn) {
    ctaBtn.addEventListener('click', function() {
        fetch('/api/watch/' + shareToken + '/cta-click', { method: 'POST' })
            .catch(function() {});
    });
}
Enter fullscreen mode Exit fullscreen mode

The .catch(function() {}) is intentional — the click should navigate to the CTA URL regardless of whether tracking succeeds. We don't want a network hiccup to block the viewer's action.

Server-side rendering, not client-side injection

The CTA card is conditionally rendered in the Go template:

{{if and .CtaText .CtaUrl}}
<div class="cta-card" id="cta-card">
    <button class="cta-dismiss" onclick="..." aria-label="Dismiss">&times;</button>
    <a href="{{.CtaUrl}}" target="_blank" rel="noopener noreferrer"
       class="cta-btn" id="cta-btn">{{.CtaText}}</a>
</div>
{{end}}
Enter fullscreen mode Exit fullscreen mode

When no CTA is configured, the HTML is absent — not hidden, absent. No unused DOM elements, no JavaScript for features that aren't active. This keeps the watch page fast for the majority of videos that don't have a CTA.

Click tracking

The RecordCTAClick endpoint is public (no auth required — viewers aren't logged in) but lightweight. It resolves the video from the share token, then records the click in a background goroutine:

func (h *Handler) RecordCTAClick(w http.ResponseWriter, r *http.Request) {
    shareToken := chi.URLParam(r, "shareToken")

    var videoID string
    err := h.db.QueryRow(r.Context(),
        `SELECT id FROM videos WHERE share_token = $1
         AND status IN ('ready', 'processing')`,
        shareToken,
    ).Scan(&videoID)
    if err != nil {
        httputil.WriteError(w, http.StatusNotFound, "video not found")
        return
    }

    go func() {
        ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
        defer cancel()
        ip := clientIP(r)
        hash := viewerHash(ip, r.UserAgent())
        h.db.Exec(ctx,
            `INSERT INTO cta_clicks (video_id, viewer_hash) VALUES ($1, $2)`,
            videoID, hash,
        )
    }()

    w.WriteHeader(http.StatusNoContent)
}
Enter fullscreen mode Exit fullscreen mode

The response returns immediately — the viewer doesn't wait for the database write. The viewerHash is a truncated SHA-256 of the IP and User-Agent, the same approach we use for view tracking. It's not a unique identifier, but it gives useful deduplication signals in analytics without storing personal data.

The data model

Two additions to the schema:

ALTER TABLE videos ADD COLUMN cta_text TEXT DEFAULT NULL;
ALTER TABLE videos ADD COLUMN cta_url TEXT DEFAULT NULL;

CREATE TABLE cta_clicks (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    video_id UUID NOT NULL REFERENCES videos(id),
    viewer_hash TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_cta_clicks_video_id ON cta_clicks(video_id);
Enter fullscreen mode Exit fullscreen mode

Both cta_text and cta_url are nullable. When both are NULL, no CTA renders. When both are set, the card appears. There's no partial state — you can't have text without a URL or vice versa.

The cta_clicks table is append-only. Each click is a row with the video ID, a viewer hash, and a timestamp. Analytics queries aggregate this into a total count and click-through rate:

var totalCtaClicks int64
h.db.QueryRow(ctx,
    `SELECT COUNT(*) FROM cta_clicks
     WHERE video_id = $1 AND created_at >= $2`,
    videoID, since,
).Scan(&totalCtaClicks)

if summary.TotalViews > 0 {
    summary.CtaClickRate = float64(totalCtaClicks) / float64(summary.TotalViews)
}
Enter fullscreen mode Exit fullscreen mode

Click-through rate = CTA clicks / total views. If a video has 200 views and 30 CTA clicks, that's a 15% click-through rate. This appears in the per-video analytics dashboard alongside view counts and daily trends.

Validation

The SetCTA handler validates both fields:

if len(*req.Text) > 100 {
    httputil.WriteError(w, http.StatusBadRequest,
        "CTA text must be 100 characters or less")
    return
}
if len(*req.URL) > 2000 {
    httputil.WriteError(w, http.StatusBadRequest,
        "CTA URL must be 2000 characters or less")
    return
}
if !strings.HasPrefix(*req.URL, "http://") &&
   !strings.HasPrefix(*req.URL, "https://") {
    httputil.WriteError(w, http.StatusBadRequest,
        "CTA URL must start with http:// or https://")
    return
}
Enter fullscreen mode Exit fullscreen mode

Text is capped at 100 characters — a CTA should be short ("Book a demo", "Start free trial", "Learn more"). URL is capped at 2000 characters and must use HTTP or HTTPS — no javascript: or data: URIs.

To clear a CTA, send null for both fields. The handler updates both columns in a single statement:

h.db.Exec(r.Context(),
    `UPDATE videos SET cta_text = $1, cta_url = $2
     WHERE id = $3 AND user_id = $4 AND status != 'deleted'`,
    req.Text, req.URL, videoID, userID,
)
Enter fullscreen mode Exit fullscreen mode

Embed page support

The CTA also works on the embeddable player at /embed/:token. The implementation is slightly different — the CTA overlay is positioned absolutely over the video rather than appearing below it, since the embed page is designed to fit inside an iframe:

.cta-overlay {
    display: none;
    position: absolute;
    top: 0; left: 0; right: 0; bottom: 0;
    background: rgba(0, 0, 0, 0.85);
    justify-content: center;
    align-items: center;
    flex-direction: column;
}
Enter fullscreen mode Exit fullscreen mode

When the viewer clicks play again, the overlay hides. This matches the behavior viewers expect from embedded players — the CTA is an interstitial, not a permanent addition.

Custom CSS

The CTA card respects the existing custom CSS system. Every element has a stable class name:

Selector Description
.cta-card CTA container
.cta-card.visible CTA container when shown
.cta-btn CTA action button
.cta-dismiss Dismiss button

You can style the CTA to match your brand:

.cta-card {
    background: linear-gradient(135deg, var(--brand-surface), #1a1a2e);
    border: 2px solid var(--brand-accent);
    border-radius: 16px;
}

.cta-btn {
    border-radius: 24px;
    text-transform: uppercase;
    letter-spacing: 0.05em;
}
Enter fullscreen mode Exit fullscreen mode

Or hide it entirely with .cta-card { display: none !important; } — though at that point, just don't set a CTA.

Try it

SendRec is open source (AGPL-3.0) and self-hostable. CTAs are live at app.sendrec.eu — upload a video, click the overflow menu in the Library, choose "Call to action", enter your text and URL, then share the link. Watch the video to the end and you'll see the button appear.

Top comments (0)