> ## Documentation Index
> Fetch the complete documentation index at: https://na-36-mintlify-aebde2c5.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Video webhooks

> Set up Livepeer Studio webhooks to receive stream and asset events. Covers endpoint setup, signature verification, event types, and troubleshooting.

export const CenteredContainer = ({children, maxWidth = "800px", padding = "0", preset = "default", width = "", minWidth = "", marginRight = "", marginBottom = "", textAlign = "", style = {}, className = "", ...rest}) => {
  const presets = {
    default: {},
    fitContent: {
      width: "fit-content",
      minWidth: "fit-content"
    },
    readable70: {
      width: "70%",
      minWidth: "fit-content"
    },
    readable80: {
      width: "80%",
      minWidth: "fit-content"
    },
    readable90: {
      width: "90%"
    },
    wide900: {
      maxWidth: "900px"
    }
  };
  const presetStyle = presets[preset] || presets.default;
  return <div className={className} style={{
    maxWidth: presetStyle.maxWidth || maxWidth,
    margin: "0 auto",
    padding: padding,
    ...presetStyle.width ? {
      width: presetStyle.width
    } : {},
    ...presetStyle.minWidth ? {
      minWidth: presetStyle.minWidth
    } : {},
    ...width ? {
      width
    } : {},
    ...minWidth ? {
      minWidth
    } : {},
    ...marginRight ? {
      marginRight
    } : {},
    ...marginBottom ? {
      marginBottom
    } : {},
    ...textAlign ? {
      textAlign
    } : {},
    ...style
  }} {...rest}>
      {children}
    </div>;
};

export const CustomDivider = ({color = "var(--lp-color-border-default)", middleText = "", spacing = "default", style = {}, className = "", ...rest}) => {
  const spacingPresets = {
    default: {
      margin: "24px 0"
    },
    overlap: {
      margin: "-1rem 0 -1rem 0"
    },
    tight: {
      margin: "0 0 -1rem 0"
    },
    section: {
      margin: "0 0 -2rem 0"
    },
    sectionOverlap: {
      margin: "-1rem 0 -2rem 0"
    },
    deepOverlap: {
      margin: "-1rem 0 -1.5rem 0"
    }
  };
  const spacingStyle = spacingPresets[spacing] || spacingPresets.default;
  return <div role="separator" aria-orientation="horizontal" className={className} style={{
    display: "flex",
    alignItems: "center",
    ...spacingStyle,
    fontSize: style?.fontSize || "16px",
    height: "fit-content",
    ...style
  }} {...rest}>
      <span style={{
    marginRight: "var(--lp-spacing-px-8)",
    opacity: 0.2
  }}>
        <Icon icon="/snippets/assets/logos/Livepeer-Logo-Symbol-Theme.svg" />
      </span>
      <div style={{
    flex: 1,
    height: "1px",
    background: "var(--lp-color-border-default)",
    opacity: 0.4
  }}></div>
      {middleText && <>
          <Icon icon="circle" size={2} />
          <span style={{
    margin: "0 8px",
    fontWeight: "bold",
    color: color,
    opacity: 0.7
  }}>
            {middleText}
          </span>
          <Icon icon="circle" size={2} />
        </>}
      <div style={{
    flex: 1,
    height: "1px",
    background: "var(--lp-color-border-default)",
    opacity: 0.4
  }}></div>
      <span style={{
    marginLeft: "var(--lp-spacing-px-8)",
    opacity: 0.2
  }}>
        <span style={{
    display: "inline-block",
    transform: "scaleX(-1)"
  }}>
          <Icon icon="/snippets/assets/logos/Livepeer-Logo-Symbol-Theme.svg" />
        </span>
      </span>
    </div>;
};

export const TableCell = ({children, align = "left", header = false, style = {}, className = "", ...rest}) => {
  const Component = header ? "th" : "td";
  return <Component className={className} style={{
    padding: "0.75rem 1rem",
    textAlign: align,
    border: header ? "none" : "1px solid var(--lp-color-border-default)",
    ...style
  }} {...rest}>
      {children}
    </Component>;
};

export const TableRow = ({children, header = false, hover = false, style = {}, className = "", ...rest}) => {
  const rowId = `table-row-${Math.random().toString(36).substr(2, 9)}`;
  return <>
      {hover && <style>{`
          #${rowId}:hover {
            background-color: var(--lp-color-bg-card);
          }
        `}</style>}
      <tr id={rowId} className={className} style={{
    ...header && ({
      backgroundColor: "var(--lp-color-accent-strong)",
      color: "var(--lp-color-on-accent)",
      fontWeight: "bold"
    }),
    ...style
  }} {...rest}>
        {children}
      </tr>
    </>;
};

export const StyledTable = ({children, variant = "default", style = {}, className = "", ...rest}) => {
  const wrapperVariants = {
    default: {
      border: "1px solid var(--lp-color-border-default)",
      backgroundColor: "var(--lp-color-bg-card)",
      overflow: "hidden"
    },
    bordered: {
      border: "2px solid var(--lp-color-accent)",
      backgroundColor: "var(--lp-color-bg-page)",
      overflow: "hidden"
    },
    minimal: {
      border: "none",
      backgroundColor: "transparent",
      overflow: "visible"
    }
  };
  return <div data-docs-styled-table-shell className={className} style={{
    width: "100%",
    padding: 0,
    margin: 0,
    ...wrapperVariants[variant],
    ...style
  }} {...rest}>
      <table data-docs-styled-table style={{
    width: "100%",
    borderCollapse: "collapse",
    borderSpacing: 0,
    margin: 0,
    backgroundColor: "transparent"
  }}>
        {children}
      </table>
    </div>;
};

<CenteredContainer preset="readable90">
  <Tip>Webhooks eliminate polling. Subscribe once; Studio calls your endpoint when events occur. Always verify the `Livepeer-Signature` header to reject spoofed requests.</Tip>
</CenteredContainer>

***

Studio sends HTTP POST requests to your endpoint when stream or asset events occur. Your server responds with `200` to acknowledge. Any other response is treated as a failure and Studio retries.

<CustomDivider middleText="Event Types" />

## Available events

<StyledTable variant="bordered">
  <thead>
    <TableRow header>
      <TableCell header>Event</TableCell>
      <TableCell header>Trigger</TableCell>
    </TableRow>
  </thead>

  <tbody>
    <TableRow>
      <TableCell>`stream.started`</TableCell>
      <TableCell>Encoder begins pushing to the RTMP ingest URL</TableCell>
    </TableRow>

    <TableRow>
      <TableCell>`stream.idle`</TableCell>
      <TableCell>Encoder disconnects or ingest stops</TableCell>
    </TableRow>

    <TableRow>
      <TableCell>`asset.created`</TableCell>
      <TableCell>Upload or URL-import request accepted</TableCell>
    </TableRow>

    <TableRow>
      <TableCell>`asset.ready`</TableCell>
      <TableCell>Transcoding complete -- asset is playable</TableCell>
    </TableRow>

    <TableRow>
      <TableCell>`asset.failed`</TableCell>
      <TableCell>Transcoding failed -- check asset status for error</TableCell>
    </TableRow>

    <TableRow>
      <TableCell>`recording.ready`</TableCell>
      <TableCell>DVR recording is available (streams with `record: true`)</TableCell>
    </TableRow>

    <TableRow>
      <TableCell>`playback.accessControl`</TableCell>
      <TableCell>Viewer requests playback on a webhook-gated stream (must respond in 250 ms)</TableCell>
    </TableRow>
  </tbody>
</StyledTable>

<CustomDivider middleText="Create a Webhook" />

## Step 1 -- Create a webhook

```typescript theme={"theme":{"light":"github-light","dark":"dark-plus"}}
import { Livepeer } from 'livepeer';

const client = new Livepeer({ apiKey: process.env.LIVEPEER_API_KEY });

const webhook = await client.webhook.create({
  name: 'asset-ready-handler',
  url: 'https://your-server.com/webhooks/livepeer',
  events: ['asset.ready', 'asset.failed'],
  // sharedSecret: 'your-secret' -- used to verify signatures
});

console.log('Webhook ID:', webhook.webhook.id);
```

<CustomDivider middleText="Receive Events" />

## Step 2 -- Handle incoming events

Set up an HTTP endpoint that accepts POST requests. Express.js example:

```javascript theme={"theme":{"light":"github-light","dark":"dark-plus"}}
const express = require('express');
const crypto = require('crypto');
const app = express();

// Use raw body for signature verification
app.use('/webhooks/livepeer', express.raw({ type: 'application/json' }));

app.post('/webhooks/livepeer', (req, res) => {
  // Verify signature before processing (see Step 3)
  const isValid = verifySignature(req.headers['livepeer-signature'], req.body, process.env.WEBHOOK_SECRET);
  if (!isValid) return res.sendStatus(401);

  const event = JSON.parse(req.body);

  switch (event.event) {
    case 'asset.ready':
      console.log('Asset ready:', event.payload.id);
      // Notify user, update database, etc.
      Break;
    case 'asset.failed':
      console.error('Asset failed:', event.payload.id, event.payload.error);
      break;
    case 'stream.started':
      console.log('Stream live:', event.payload.id);
      break;
  }

  res.sendStatus(200);
});
```

<CustomDivider middleText="Signature Verification" />

## Step 3 -- Verify webhook signatures

Studio signs every request with HMAC-SHA256. Verifying the signature rejects spoofed or tampered requests.

The `Livepeer-Signature` header format:

```
Livepeer-Signature: t=1710000000,v1=abc123def456...
```

Verification logic:

```javascript theme={"theme":{"light":"github-light","dark":"dark-plus"}}
function verifySignature(signatureHeader, rawBody, secret) {
  if (!signatureHeader) return false;

  // Parse t= and v1= from the header
  const parts = Object.fromEntries(
    signatureHeader.split(',').map(part => part.split('='))
  );
  const timestamp = parts['t'];
  const signature = parts['v1'];

  if (!timestamp || !signature) return false;

  // Replay attack protection: reject events older than 5 minutes
  const age = Date.now() / 1000 - parseInt(timestamp);
  if (age > 300) return false;

  // Compute expected signature
  const payload = `${timestamp}.${rawBody}`;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');

  // Constant-time comparison to prevent timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(signature, 'hex'),
    Buffer.from(expected, 'hex')
  );
}
```

<CustomDivider middleText="Local Testing" />

## Testing locally

Use [ngrok](https://ngrok.com) to expose your local server during development:

```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}}
# Start your server
node app.js  # listening on port 3000

# In a second terminal
ngrok http 3000
# ngrok provides a public URL, e.g. Https://abc123.ngrok.io
```

Register the ngrok URL as your webhook endpoint in Studio, or via API with the URL from ngrok.

Studio's webhook dashboard at [https://livepeer.studio/dashboard/developers/webhooks](https://livepeer.studio/dashboard/developers/webhooks) shows delivery history and lets you resend failed events.

<CustomDivider middleText="Payload Structure" />

## Webhook payload structure

```json theme={"theme":{"light":"github-light","dark":"dark-plus"}}
{
  "webhookId": "web_abc123",
  "createdAt": "2026-04-05T10:00:00Z",
  "timestamp": "2026-04-05T10:01:23Z",
  "event": "asset.ready",
  "payload": {
    "id": "asset_xyz789",
    "name": "my-video.mp4",
    "status": { "phase": "ready" },
    "playbackId": "pla_abc123"
  }
}
```

The `payload` object shape varies by event type. Refer to the [Studio API Reference](https://livepeer.studio/docs) for per-event schemas.

<CustomDivider />

## Related pages

<CardGroup cols={2}>
  <Card title="Access Control" icon="lock" href="/v2/developers/guides/video/access-control">
    Use `playback.accessControl` webhooks to gate content dynamically.
  </Card>

  <Card title="Create a Livestream" icon="video" href="/v2/developers/guides/video/create-livestream">
    Create streams and attach webhook event subscriptions.
  </Card>
</CardGroup>
