Partner API base path is /api/v1 on https://api.dcast.pro. This site documents the live surface; use the Quickstart QA curls to validate keys and routes.

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 + pathPurpose
GET /v1/me/webhooksList your registered webhooks.
POST /v1/me/webhooksRegister a new endpoint. Returns the signingSecret in plaintext exactly once.
GET /v1/me/webhooks/:idRead one webhook (denormalized delivery stats included).
PATCH /v1/me/webhooks/:idUpdate url, events, isActive, description. The secret is NOT mutable here — use rotate-secret.
DELETE /v1/me/webhooks/:idDelete the webhook and all of its delivery log rows.
POST /v1/me/webhooks/:id/rotate-secretIssue a new signing secret. The old one becomes invalid immediately.
POST /v1/me/webhooks/:id/testFire a synthetic webhook.test event so you can smoke your receiver before any real event arrives.
POST /v1/me/webhooks/:id/simulateDry-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/replayStripe-style re-fire of a past delivery against the current URL + secret.
GET /v1/me/webhooks/:id/deliveriesCursor-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/eventsCatalogue 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 typeFires whendata payload
stream.liveAn 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.endedThe ingest signal is lost (encoder disconnects; hasSignal flips false). The signal-lost counterpart of stream.live.{ "streamId": "...", "endedAt": "<ISO-8601>", "durationSec": null }
stream.broadcast_startedisLive 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_stoppedisLive 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 on hasSignal): 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 on isLive): subscribe to these to know “the broadcast is now live to viewers” — mirrors the On-Air button state and the public master.m3u8 going 404↔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 side
  • DCAST-Event-Type: <string> — e.g. webhook.test
  • Content-Type: application/json
  • User-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', 200

Dry-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/webhook

Errors: 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-Id on 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.

Webhooks & event delivery — dcast.pro API docs