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');
});
}
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() {});
});
}
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">×</button>
<a href="{{.CtaUrl}}" target="_blank" rel="noopener noreferrer"
class="cta-btn" id="cta-btn">{{.CtaText}}</a>
</div>
{{end}}
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)
}
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);
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)
}
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
}
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,
)
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;
}
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;
}
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)