Webhooks
Webhooks
SkaelReach delivers real-time event notifications via webhook HTTP POST requests. The system uses a two-tier model: each organization has an org-level webhook that fires across all campaigns, and each campaign can additionally have its own campaign-level webhook.
Two-Tier Secret Model
Two separate HMAC secrets are used to sign webhook deliveries, one per scope:
| Scope |
Secret Location |
Signs |
| Org |
organizations.skaelreach_webhook_secret |
All org-level webhook deliveries |
| Campaign |
video_campaigns.webhook_secret |
All campaign-level webhook deliveries |
Both secrets are auto-generated lazily the first time a webhook is saved and can be rotated independently at any time.
Webhook Scopes
Campaign-Scoped Webhook
Configured per-campaign on the campaign edit page (/skaelreach/campaigns/{id}). Fires only for events belonging to that specific campaign. Signed with the campaign's secret.
Org-Scoped Webhook
Configured at /skaelreach/webhooks. Fires for matching events from every campaign in the organization by default. You can disable the org webhook for a specific campaign from the campaign edit page. Signed with the org's secret.
Dual-Scope Same-URL Gotcha
If you configure both a campaign-level webhook and an org-level webhook for the same event pointing at the same URL, your receiver will get two POST requests per event with different signatures. The webhook_scope field in the payload tells them apart. You can:
- Accept both and handle them differently based on
webhook_scope.
- Delete one of the duplicate configurations.
- Disable the org-level webhook for that specific campaign.
Payload Structure
Every webhook POST body includes a webhook_scope field so your receiver can select the correct secret for signature validation.
{
"webhook_scope": "org",
"event": "video.finished",
"campaign_id": 42,
"prospect": {
"id": 123,
"token": "abc123def456ghi789012",
"screenshot_url": "https://example.com/screenshot.png",
"custom_vars": {
"lead_id": "abc-123",
"source": "email"
}
},
"metadata": {
"user_agent": "Mozilla/5.0...",
"ip": "1.2.3.4",
"referrer": "https://example.com"
},
"timestamp": "2026-04-30T12:00:00+00:00"
}
For video.progress.* events, metadata includes a percent field (25, 50, 75).
HMAC Signature Verification
Every webhook request includes two headers:
| Header |
Description |
X-SkaelReach-Signature |
HMAC-SHA256 hex digest |
X-SkaelReach-Timestamp |
Unix timestamp in seconds |
Signature Algorithm
HMAC-SHA256(timestamp + "." + raw_body, secret)
Use the secret that matches the webhook_scope value in the payload body.
Replay Protection
Reject any request where the timestamp is more than 5 minutes old to prevent replay attacks.
Node.js Example
const crypto = require('crypto');
function verifyWebhook(req, orgSecret, campaignSecret) {
const sig = req.headers['x-skaelreach-signature'];
const ts = req.headers['x-skaelreach-timestamp'];
if (!sig || !ts) return false;
// Replay protection
if (Math.abs(Date.now() / 1000 - parseInt(ts, 10)) > 300) return false;
const scope = req.body.webhook_scope;
const secret = scope === 'org' ? orgSecret : campaignSecret;
if (!secret) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(ts + '.' + req.rawBody)
.digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
}
Python Example
import hashlib
import hmac
import time
def verify_webhook(headers, raw_body, body, org_secret, campaign_secret):
sig = headers.get('X-SkaelReach-Signature', '')
ts = headers.get('X-SkaelReach-Timestamp', '')
if not sig or not ts:
return False
if abs(time.time() - int(ts)) > 300:
return False
scope = body.get('webhook_scope')
secret = org_secret if scope == 'org' else campaign_secret
if not secret:
return False
expected = hmac.new(secret.encode(), f"{ts}.{raw_body}".encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)
Available Events
| Event |
Description |
prospect.created |
A prospect was added to a campaign |
prospect.ready |
Prospect render completed and the landing page is live |
prospect.failed |
Prospect render failed |
page.opened |
Prospect opened their personalized landing page |
video.started |
Viewer started playing the video |
video.progress.25 |
Viewer reached 25% watch progress |
video.progress.50 |
Viewer reached 50% watch progress |
video.progress.75 |
Viewer reached 75% watch progress |
video.finished |
Viewer completed the video |
cta.clicked |
Viewer clicked the call-to-action button |
footer_link.clicked |
Viewer clicked a footer link |
See app/Enums/CampaignWebhookEvent.php for the canonical list.
Unique-Delivery Toggle (`is_unique`)
Each webhook subscription has an is_unique toggle (default ON for new webhooks).
| Value |
Behavior |
true |
Fires at most once per prospect for that event. Ideal for CRM/Make/Zapier flows. |
false |
Fires every time the event occurs (multiple plays, page reloads, etc.). |
Org-level webhooks with is_unique=true fire at most once per prospect across the entire organization, because each prospect belongs to exactly one campaign.
Fetching Secrets
Dashboard
| What |
Where |
| Org secret + full campaign-secret inventory |
/skaelreach/webhooks |
| Per-campaign secret |
Campaign edit page (/skaelreach/campaigns/{id}) |
API
Both endpoints require Authorization: Bearer <api_key>.
Org-wide overview (paginated, includes all secrets):
GET /api/skaelreach/webhook-config
Single campaign with its webhook secret and subscriptions:
GET /api/video-campaigns/{id}
The campaign response includes a webhook_secret field and a webhooks array.
Rotating Secrets
Rotation immediately invalidates the previous secret. Update your Make/N8N/Zapier validators with the new secret as soon as you rotate, or webhook deliveries will fail signature verification.
| Secret |
Dashboard Endpoint |
Access |
| Org secret |
POST /skaelreach/webhooks/rotate-org-secret |
Admin only |
| Campaign secret |
POST /skaelreach/campaigns/{id}/rotate-webhook-secret |
Admin only |
Retry Behavior
Failed webhook deliveries are retried up to 3 times with exponential backoff:
| Attempt |
Delay |
| 1st retry |
10 seconds |
| 2nd retry |
30 seconds |
| 3rd retry |
60 seconds |
A delivery is considered failed if the server returns a non-2xx status code, the connection times out (10 second timeout), or the server is unreachable.
Migration Note
If you previously fetched secrets from the per-row webhook field in the legacy campaign edit form, those secrets have been rotated as part of the two-tier secret migration. Re-fetch your new secrets from /skaelreach/webhooks after deploying this update.