Webhooks & event delivery
Register an HTTPS endpoint and dcast will POST signed event payloads to it as things happen on your account — video processing finishes, a stream goes live, a subscription is created, and so on. Receivers verify the signature using a per-webhook secret to reject forged events and replays.
Status: registration, signing, the synthetic webhook.test event, the events in the event catalogue below and automatic retries of failed deliveries all work today. purchase.completed is not in the catalogue.
Endpoints
| Method + path | Purpose |
|---|---|
GET /v1/me/webhooks | List your registered webhooks. |
POST /v1/me/webhooks | Register a new endpoint. Returns the signingSecret in plaintext exactly once. |
GET /v1/me/webhooks/:id | Read one webhook (denormalized delivery stats included). |
PATCH /v1/me/webhooks/:id | Update url, events, isActive, description. The secret is NOT mutable here — use rotate-secret. |
DELETE /v1/me/webhooks/:id | Delete the webhook and all of its delivery log rows. |
POST /v1/me/webhooks/:id/rotate-secret | Issue a new signing secret. The old one becomes invalid immediately. |
POST /v1/me/webhooks/:id/test | Fire a synthetic webhook.test event so you can smoke your receiver before any real event arrives. |
POST /v1/me/webhooks/:id/simulate | Dry-fire. Renders the payload + DCAST-Signature inline for any event type without making an outbound HTTP request. See “Dry-fire / simulate” below. |
POST /v1/me/webhooks/:id/deliveries/:deliveryId/replay | Stripe-style re-fire of a past delivery against the current URL + secret. |
GET /v1/me/webhooks/:id/deliveries | Cursor-paginated list of recent delivery attempts (event type and id, response status and body, latency, error message, attempt number; the sent payload is not stored here). |
GET /v1/me/webhooks/events | Catalogue of event-type strings you can subscribe to. |
Register a webhook
curl -sS -X POST https://api.dcast.pro/api/v1/me/webhooks \
-H "Authorization: Bearer pk_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-server.example.com/dcast-webhook",
"events": ["video.processing.completed", "stream.*"],
"description": "production receiver"
}'Response includes a signingSecret like whsec_4c7fdc55561fd9d08fa…. Store it now — this is the only time the API returns it in plaintext. If you lose it, call POST /me/webhooks/:id/rotate-secret for a new one (the old secret stops working immediately).
Use events: [] (empty array) to subscribe to every event type. Use "video.*"-style wildcards to subscribe to a whole family.
Event catalogue
The authoritative, always-current list of event-type strings is GET /v1/me/webhooks/events. The stream lifecycle events most integrations subscribe to:
| Event type | Fires when | data payload |
|---|---|---|
stream.live | An encoder connects and SRS detects an active publish (hasSignal flips true; preview becomes available). This is the signal-arrived event — not the broadcast going live to viewers. | { "streamId": "...", "status": "HAS_SIGNAL" | "ACTIVE", "wentLiveAt": "<ISO-8601>" } |
stream.ended | The ingest signal is lost (encoder disconnects; hasSignal flips false). The signal-lost counterpart of stream.live. | { "streamId": "...", "endedAt": "<ISO-8601>", "durationSec": null } |
stream.broadcast_started | isLive transitions false→true — the broadcast goes live to viewers and the public master.m3u8 starts serving. Fires on the partner-API POST /streams/:id/start and the cabinet «В эфир» button. De-duplicated to exactly once per stream per minute, so an API + cabinet double-trigger (or the 3-pool backend) won't double-deliver. | { "streamId": "...", "isLive": true, "startedAt": "<iso>|null" } |
stream.broadcast_stopped | isLive transitions true→false via a user-driven stop. Fires on the partner-API POST /streams/:id/stop (a strict alias of /end) and the cabinet stop button. Same per-minute de-duplication. | { "streamId": "...", "isLive": false, "endedAt": "<iso>", "durationSec": <int|null> } |
Signal vs broadcast — pick the right pair. There are two independent stream lifecycles, keyed on two different flags (the same hasSignal-vs-isLive distinction that drives the 3-state go-live button):
- Signal-based (
stream.live/stream.ended, keyed onhasSignal): subscribe to these to know “an encoder just connected / disconnected” — mirrors the waiting↔ready preview state. - Broadcast-based (
stream.broadcast_started/stream.broadcast_stopped, keyed onisLive): subscribe to these to know “the broadcast is now live to viewers” — mirrors the On-Air button state and the publicmaster.m3u8going404↔200.
A typical broadcast emits all four in order: stream.live (encoder connects) → stream.broadcast_started (go live) → stream.broadcast_stopped (stop) → stream.ended (encoder disconnects). Subscribe to stream.* to receive the whole family.
durationSec semantics on stream.broadcast_stopped. This field is the broadcast-live duration in whole seconds — measured from when the broadcast went live (stream.broadcast_started / POST /streams/:id/start) to the stop. It is null when the broadcast never went live. Because hasSignal (an encoder is connected) is independent of isLive (viewers can watch), a stream that received signal but was never started for viewers produces a stream.broadcast_stopped with durationSec: null — do not treat null as a zero-length broadcast, and do not expect a duration for a signal-only stream. Use the stream.ended event (signal lost) if you need the encoder-session boundary instead.
URL restrictions (SSRF guard)
The webhook url must point to a publicly routable HTTPS endpoint. The following are rejected at register time AND re-checked at every delivery attempt:
- Any scheme other than
https://—http://,ftp://,file://,gopher://are blocked. - Hostnames that resolve to RFC1918 private ranges (
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16), loopback (127.0.0.0/8,::1), link-local (169.254.0.0/16— cloud metadata range), CGNAT (100.64.0.0/10), IPv6 ULA (fc00::/7), or any non-unicast range. - IP literals in any of the above ranges, including IPv4-mapped IPv6 such as
[::ffff:192.168.0.1]. - Reserved hostnames:
localhost, and any name ending in.local,.localhost,.internal,.lan,.intranet. - URLs containing userinfo (
https://user:pass@host/). - Length over 2048 characters.
At delivery time the validated hostname's IP is pinned into the outbound TCP connect, so a DNS-rebind between register and dial cannot redirect a delivery to an internal target. A delivery that fails this guard is logged with errorMessage: "ssrf_guard: <reason>" in GET /me/webhooks/:id/deliveries.
Delivery format
Each delivery is an HTTPS POST with these headers:
DCAST-Signature: t=<unix-ts>,v1=<hex-sig>DCAST-Event-Id: <uuid-v4>— idempotency key on the receiver sideDCAST-Event-Type: <string>— e.g.webhook.testContent-Type: application/jsonUser-Agent: dcast-webhook/1.0
The body shape:
{
"id": "<uuid-v4>",
"type": "video.processing.completed",
"created": 1779936824,
"livemode": true,
"data": { ... event-specific payload ... }
}Signature verification
Compute HMAC-SHA256(`{t}.{rawBody}`, signingSecret) in hex and compare with the v1= field using a constant-time comparison. Reject the request if |now - t| > 300 seconds — this blocks captured payloads being replayed days later.
Node.js
const crypto = require('crypto');
const TOLERANCE_SEC = 5 * 60;
function verifyDcastSignature(rawBody, headerValue, secret) {
if (!headerValue) return false;
const parts = Object.fromEntries(
headerValue.split(',').map(kv => kv.trim().split('=')).filter(p => p.length === 2)
);
const t = Number(parts.t);
const v1 = parts.v1;
if (!Number.isFinite(t)) return false;
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SEC) return false;
if (typeof v1 !== 'string' || !v1) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
if (expected.length !== v1.length) return false;
return crypto.timingSafeEqual(
Buffer.from(expected, 'utf8'),
Buffer.from(v1, 'utf8')
);
}
// Inside your Express handler — read RAW body, not parsed JSON.
app.post('/dcast-webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const raw = req.body.toString('utf8');
if (!verifyDcastSignature(raw, req.headers['dcast-signature'], process.env.DCAST_WEBHOOK_SECRET)) {
return res.status(400).send('bad signature');
}
const event = JSON.parse(raw);
// ... handle event.type, dedupe on event.id ...
res.status(200).send('ok');
}
);Python (Flask)
import hmac, hashlib, time
from flask import request, abort
TOLERANCE_SEC = 5 * 60
def verify_dcast_signature(raw_body: bytes, header_value: str, secret: str) -> bool:
if not header_value:
return False
parts = dict(
kv.strip().split('=', 1)
for kv in header_value.split(',')
if '=' in kv
)
try:
t = int(parts['t'])
v1 = parts['v1']
except (KeyError, ValueError):
return False
if abs(time.time() - t) > TOLERANCE_SEC:
return False
expected = hmac.new(
secret.encode('utf-8'),
f"{t}.".encode('utf-8') + raw_body,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, v1)
@app.post('/dcast-webhook')
def dcast_webhook():
raw = request.get_data()
if not verify_dcast_signature(raw, request.headers.get('DCAST-Signature', ''), os.environ['DCAST_WEBHOOK_SECRET']):
abort(400)
# ... handle request.json ...
return 'ok', 200Dry-fire / simulate (no outbound delivery)
Reproduce a delivery without firing the actual HTTP request. Useful while developing your receiver before pointing dcast at a public URL, or for replaying a known event shape against a sandbox locally.
POST https://api.dcast.pro/api/v1/me/webhooks/{webhookId}/simulate
Authorization: Bearer pk_YOUR_KEY_HERE
Content-Type: application/json
{
"event": "video.processing.completed",
"payload": { /* optional — override sample data with your own */ }
}
# Response (200): a representative request with a valid HMAC (see the note below
# for how real deliveries differ)
{
"success": true,
"data": {
"deliveryUrl": "https://your.app/webhook",
"wouldDeliver": true, // false if webhook isActive=false
// or its events list doesn't match
"renderedRequest": {
"method": "POST",
"url": "https://your.app/webhook",
"headers": {
"Content-Type": "application/json",
"User-Agent": "DCast-Webhooks/1.0",
"DCAST-Event-Id": "evt_dry_<hex>",
"DCAST-Event-Type": "video.processing.completed",
"DCAST-Event-Timestamp": "<unix>",
"DCAST-Signature": "t=<unix>,v1=<hex>"
},
"body": { "id": "evt_dry_<hex>", "type": "...", "created": "<unix>", "data": {...} },
"bodyString": "<exact bytes the signature is computed over>"
},
"note": "No outbound HTTP request was made."
}
}Real deliveries differ from this rendering in three ways: the User-Agent is dcast-webhook/1.0, the body also carries livemode (the platform environment, not the key mode), and no DCAST-Event-Timestamp header is sent — read t from DCAST-Signature.
Reproduce locally to test your receiver:
curl -X POST <renderedRequest.url> \
-H 'Content-Type: application/json' \
-H 'DCAST-Event-Id: <id>' \
-H 'DCAST-Event-Type: <type>' \
-H 'DCAST-Event-Timestamp: <ts>' \
-H 'DCAST-Signature: <sig>' \
-d '<bodyString>' \
http://localhost:YOUR_PORT/webhookErrors: 400 UNKNOWN_EVENT if event is not one from GET /me/webhooks/events. 404 NOT_FOUND if the webhook id is not yours.
Receiver expectations
- Respond 2xx within 10 seconds. Non-2xx and timeouts are recorded as failures and visible in
GET /me/webhooks/:id/deliveries. - Retries: a failed delivery is retried up to 6 more times (after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h), 7 attempts in total. Dedupe on
DCAST-Event-Idon your side; receivers must tolerate replays. - Always read the raw body before JSON-parsing — the signature is computed over the raw bytes. Frameworks that auto-parse JSON (e.g.
express.json()) re-serialize and may insert/remove whitespace, breaking the signature.
Tolerance window
Reject any delivery whose t differs from your server clock by more than 300 seconds. dcast clocks are NTP-synced; if your receiver also is, legitimate deliveries arrive within seconds. A wider tolerance leaks replay surface to anyone who ever captured a single signed payload.
Rotating the signing secret
Call POST /me/webhooks/:id/rotate-secret on suspected leak. The new secret is returned in plaintext once; the old secret stops signing immediately. Receivers should accept ONLY the current secret — there is no overlap window.
Monetization events & financial scope
Financial webhook events (checkout / subscription / payment lifecycle) report revenue-bearing data. The corresponding read endpoints you would call to reconcile them — /monetization/revenue, /monetization/payments, /me/payouts, /analytics/summary, /purchases,/me/subscriptions — require an Owner-class key (FULL_ACCESS scope) or an explicit finance:read permission. READ_ONLY and STANDARD keys get 403 FINANCIAL_SCOPE_REQUIRED. See the financial scope policy in Authentication and the Monetization API.
Platform webhooks (NOT partner-configurable)
Stripe and other inbound platform webhooks hit our servers (e.g. /api/webhooks/stripe) for billing. They are a separate surface from the Partner API key integration and are not configurable from this page.
