SCTE-35 Ad Signaling for Automated Spot Insertion
Every local avail in a linear schedule eventually has to become a machine-readable cue riding inside the transport stream, or no downstream splicer, ad server, or server-side ad insertion (SSAI) system knows where a national break opens for a local spot. Get that translation wrong and the failure is silent until it is expensive: a break that opens two frames early clips programming, a duration that drifts by 90,000 ticks overruns into content, and an avail with no matching return cue leaves the stream stuck in a slate. This page treats SCTE-35 cue generation as a deterministic subsystem within Spot Scheduling Validation & Rule Engines: it takes a validated traffic avail, arms the automation system with an SCTE-104 trigger, and encodes the resulting splice_info_section that carries the break boundary frame-accurately to playout. The material is written for two readers. Traffic managers get the plain-language chain from a scheduled avail to an on-air splice; Python automation builders get a deployable, byte-level encoder with strict typing, Pydantic v2 validation, and structured logging.
SCTE-35 signaling sits downstream of schedule generation and upstream of playout and reconciliation. It consumes the same normalized avail records that feed the spot schema, and every cue it emits must later match the airing captured for as-run reconciliation — a splice that fired but never reconciles against the schedule is a billing discrepancy in waiting. Two closely related tasks live under this guide: the bit-level construction of a splice command in How to Insert SCTE-35 Splice Points in Python, and the upstream automation path in Mapping Spot Schedules to SCTE-104 Triggers.
Concept & Data Model
SCTE-35 is a standard for carrying splice and segmentation cues in an MPEG-2 transport stream. A cue is a splice_info_section — a small binary table (table_id 0xFC) that a decoder finds on its own PID and acts on at a precise presentation timestamp. Two command shapes matter for spot insertion. A splice_insert (command type 0x05) carries the break boundary directly, including whether the stream is going out of network (into a local avail) or returning, and how long the break lasts. A time_signal (command type 0x06) carries only a timestamp and defers all meaning to one or more segmentation_descriptor structures — the modern, richer path used for placement-opportunity signaling.
The engine models four entities, each with a stable schema so that avail intent stays decoupled from wire encoding.
| Entity | Purpose | Key fields | Constraints |
|---|---|---|---|
AvailTrigger |
The scheduled break intent from traffic | avail_id, spot_id, out_of_network, start_utc, duration_ms, avail_num |
duration_ms > 0; start_utc timezone-aware UTC |
SpliceEvent |
A splice_insert command’s parameters |
splice_event_id, out_of_network, pts_time, break_duration, auto_return, unique_program_id |
splice_event_id fits 32 bits; pts_time fits 33 bits |
SegmentationDescriptor |
A placement-opportunity marker on a time_signal |
segmentation_event_id, segmentation_type_id, segmentation_upid, segmentation_duration |
identifier == "CUEI"; UPID length matches bytes |
CueMessage |
The full splice_info_section + hex encoder |
pts_adjustment, tier, command, descriptors |
serializes to byte-aligned hex with a valid CRC-32 |
Timing is the field that punishes carelessness. SCTE-35 timestamps are 33-bit values on the 90 kHz system clock — one second is 90,000 ticks — so a break at 19:30:00.000 is expressed as a pts_time derived from the program clock reference, not wall-clock UTC. The section also carries a 33-bit pts_adjustment that every device in the chain may add to pts_time (modulo 2³³) to compensate for re-stamping as the stream is re-multiplexed. The traffic system reasons in UTC and milliseconds; the encoder is the boundary that converts to ticks and clamps to 33 bits.
The segmentation_type_id is a controlled vocabulary, and the four codes that dominate ad insertion are the placement-opportunity pairs: 0x34 (Provider Placement Opportunity Start) and 0x35 (Provider Placement Opportunity End) mark an avail the content provider owns, while 0x36/0x37 mark the distributor’s. A start descriptor without its matching end is the single most common decode fault, because the stream never learns when to rejoin the network feed.
Figure — Signaling data flow: a validated avail becomes an SCTE-104 trigger, the splicer converts it to an SCTE-35 splice_info_section on a dedicated PID, and the aired cue is later reconciled against the schedule.
Implementation Approach
Three design decisions shape a production cue generator, and each is a trade-off worth naming.
Encode at the boundary, reason in UTC everywhere else. The traffic system, the rule engines, and the audit log all speak wall-clock UTC and integer milliseconds. Only the encoder converts to 90 kHz ticks and clamps to 33 bits. Concentrating the tick arithmetic in one class means DST transitions, leap-second smearing, and clock re-stamping are handled once, and the rest of the stack never carries a pts_time it could accidentally compare to a UTC field. This mirrors the separation the spot schema draws between canonical business fields and vendor wire formats.
Prefer time_signal + segmentation for placement opportunities, keep splice_insert for simple avails. A bare splice_insert is universally understood and is the right tool for a fixed-duration local break with auto_return. But SSAI and dynamic ad insertion need the richer segmentation_descriptor — a UPID identifying the break, a segmentation_duration, and the 0x34/0x35 provider placement-opportunity pair. The engine can emit either from the same AvailTrigger, selected by whether the downstream consumer needs addressable metadata.
Fail closed on timing, never silently truncate. A pts_time that overflows 33 bits or a break_duration longer than the stream can express is a hard rejection, not a wrap-around. A wrapped timestamp does not error at the encoder; it fires the splice at the wrong moment hours later. Validation rejects out-of-range values before a byte is written, and the same discipline that arms make-good routing governs a rejected cue — it is escalated, not shipped.
Production Python Implementation
The module below is deployable as a library or an encoder-side worker. It uses Pydantic v2 for schema enforcement, a hand-built bit writer and MPEG CRC-32 so it runs with no SCTE-specific dependency, and structured logging in the traffic-ops format timestamp | level | module | spot_id.
from __future__ import annotations
import logging
from datetime import datetime, timezone
from enum import IntEnum
from typing import Optional
from pydantic import BaseModel, Field, field_validator, model_validator
# --- Structured logging: timestamp | level | module | spot_id ---------------
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
)
logger = logging.getLogger("scte35_encoder")
def _log(level: int, spot_id: str, msg: str) -> None:
# Prepend the spot_id so every audit line is greppable by placement.
logger.log(level, "%s | %s", spot_id, msg)
TICKS_PER_SECOND = 90_000 # SCTE-35 system clock runs at 90 kHz
MASK_33 = (1 << 33) - 1 # pts_time / pts_adjustment are 33-bit fields
MASK_40 = (1 << 40) - 1 # segmentation_duration is a 40-bit field
class SegmentationType(IntEnum):
# The placement-opportunity vocabulary that dominates ad insertion.
PROVIDER_PLACEMENT_START = 0x34
PROVIDER_PLACEMENT_END = 0x35
DISTRIBUTOR_PLACEMENT_START = 0x36
DISTRIBUTOR_PLACEMENT_END = 0x37
def ms_to_ticks(duration_ms: int) -> int:
# Convert integer milliseconds to 90 kHz ticks; break durations round to
# the nearest tick so a 30.000s avail is exactly 2_700_000 ticks.
return round(duration_ms * TICKS_PER_SECOND / 1000)
class BitWriter:
"""Minimal MSB-first bit writer for byte-aligned SCTE-35 sections."""
def __init__(self) -> None:
self._bits: list[int] = []
def write(self, value: int, nbits: int) -> None:
if value < 0 or value >= (1 << nbits):
raise ValueError(f"value {value} does not fit in {nbits} bits")
for i in range(nbits - 1, -1, -1):
self._bits.append((value >> i) & 1)
def flag(self, on: bool) -> None:
self._bits.append(1 if on else 0)
def to_bytes(self) -> bytes:
# SCTE-35 sections are byte-aligned; pad the final byte with zeros.
while len(self._bits) % 8:
self._bits.append(0)
out = bytearray()
for i in range(0, len(self._bits), 8):
byte = 0
for bit in self._bits[i:i + 8]:
byte = (byte << 1) | bit
out.append(byte)
return bytes(out)
def mpeg_crc32(data: bytes) -> int:
# ISO/IEC 13818-1 Annex B CRC-32 as used by SCTE-35: poly 0x04C11DB7,
# init 0xFFFFFFFF, no input/output reflection, no final XOR.
crc = 0xFFFFFFFF
for byte in data:
crc ^= byte << 24
for _ in range(8):
crc = ((crc << 1) ^ 0x04C11DB7) & 0xFFFFFFFF if crc & 0x80000000 \
else (crc << 1) & 0xFFFFFFFF
return crc
class AvailTrigger(BaseModel):
"""The scheduled break intent handed to the encoder by traffic."""
avail_id: str
spot_id: str
out_of_network: bool = True # True = leaving network for a local avail
start_utc: datetime # Frame boundary the break opens at
duration_ms: int = Field(gt=0) # Break length; must be positive
avail_num: int = Field(ge=0, le=255)
unique_program_id: int = Field(default=0, ge=0, le=0xFFFF)
@field_validator("start_utc")
@classmethod
def _utc_aware(cls, v: datetime) -> datetime:
# A naive datetime crossing a DST boundary silently shifts the splice.
if v.tzinfo is None:
raise ValueError("start_utc must be timezone-aware (UTC)")
return v.astimezone(timezone.utc)
class SpliceEvent(BaseModel):
"""Parameters of a splice_insert command (SCTE-35 command type 0x05)."""
splice_event_id: int = Field(ge=0, le=0xFFFFFFFF)
out_of_network: bool
pts_time: int = Field(ge=0) # 90 kHz ticks on the program clock
break_duration: int = Field(ge=0) # 90 kHz ticks; 0 if not carried
auto_return: bool = True # Stream self-returns at break end
unique_program_id: int = Field(ge=0, le=0xFFFF)
avail_num: int = Field(ge=0, le=255)
@field_validator("pts_time")
@classmethod
def _pts_fits_33(cls, v: int) -> int:
# A silent wrap fires the splice ~26.5 hours off. Reject, never wrap.
if v > MASK_33:
raise ValueError("pts_time overflows the 33-bit SCTE-35 field")
return v
def encode_command(self) -> bytes:
w = BitWriter()
w.write(self.splice_event_id, 32)
w.flag(False) # splice_event_cancel_indicator = 0
w.write(0x7F, 7) # reserved
w.flag(self.out_of_network) # out_of_network_indicator
w.flag(True) # program_splice_flag (whole program)
w.flag(self.break_duration > 0) # duration_flag
w.flag(False) # splice_immediate_flag = 0 (timed)
w.write(0x0F, 4) # reserved
# splice_time(): time_specified_flag = 1, then a 33-bit pts_time.
w.flag(True)
w.write(0x3F, 6) # reserved
w.write(self.pts_time & MASK_33, 33)
if self.break_duration > 0:
# break_duration(): auto_return, reserved, 33-bit duration.
w.flag(self.auto_return)
w.write(0x3F, 6)
w.write(self.break_duration & MASK_33, 33)
w.write(self.unique_program_id, 16)
w.write(self.avail_num, 8)
w.write(0, 8) # avails_expected (0 = unspecified)
return w.to_bytes()
class SegmentationDescriptor(BaseModel):
"""A segmentation_descriptor carried on a time_signal (tag 0x02)."""
segmentation_event_id: int = Field(ge=0, le=0xFFFFFFFF)
segmentation_type_id: SegmentationType
segmentation_upid: bytes # e.g. an ad-ID or break identifier
segmentation_duration: int = Field(ge=0, le=MASK_40)
segment_num: int = Field(default=0, ge=0, le=255)
segments_expected: int = Field(default=0, ge=0, le=255)
def encode(self) -> bytes:
body = BitWriter()
body.write(0x43554549, 32) # identifier "CUEI"
body.write(self.segmentation_event_id, 32)
body.flag(False) # segmentation_event_cancel_indicator
body.write(0x7F, 7) # reserved
body.flag(True) # program_segmentation_flag
body.flag(self.segmentation_duration > 0) # duration_flag
body.flag(True) # delivery_not_restricted_flag
body.write(0x1F, 5) # reserved (restrictions omitted)
if self.segmentation_duration > 0:
body.write(self.segmentation_duration & MASK_40, 40)
body.write(0x0F, 8) # segmentation_upid_type = 0x0F (ADI)
body.write(len(self.segmentation_upid), 8)
for octet in self.segmentation_upid:
body.write(octet, 8)
body.write(int(self.segmentation_type_id), 8)
body.write(self.segment_num, 8)
body.write(self.segments_expected, 8)
payload = body.to_bytes()
# Prefix the splice_descriptor_tag and length before the CUEI body.
return bytes([0x02, len(payload)]) + payload
class CueMessage(BaseModel):
"""A full splice_info_section with a hex encoder and CRC-32."""
pts_adjustment: int = Field(default=0, ge=0, le=MASK_33)
tier: int = Field(default=0xFFF, ge=0, le=0xFFF)
splice_event: Optional[SpliceEvent] = None
time_signal_pts: Optional[int] = None
descriptors: list[SegmentationDescriptor] = Field(default_factory=list)
@model_validator(mode="after")
def _exactly_one_command(self) -> "CueMessage":
# A section carries one command: splice_insert OR time_signal.
if (self.splice_event is None) == (self.time_signal_pts is None):
raise ValueError("set exactly one of splice_event / time_signal_pts")
return self
def _command_bytes(self) -> tuple[int, bytes]:
if self.splice_event is not None:
return 0x05, self.splice_event.encode_command() # splice_insert
w = BitWriter() # time_signal
w.flag(True) # time_specified
w.write(0x3F, 6)
w.write((self.time_signal_pts or 0) & MASK_33, 33)
return 0x06, w.to_bytes()
def to_hex(self) -> str:
command_type, command = self._command_bytes()
descriptor_bytes = b"".join(d.encode() for d in self.descriptors)
body = BitWriter()
body.write(0, 8) # protocol_version = 0
body.flag(False) # encrypted_packet = 0
body.write(0, 6) # encryption_algorithm = 0
body.write(self.pts_adjustment & MASK_33, 33)
body.write(0, 8) # cw_index
body.write(self.tier, 12)
body.write(len(command), 12) # splice_command_length
body.write(command_type, 8)
for octet in command:
body.write(octet, 8)
body.write(len(descriptor_bytes), 16) # descriptor_loop_length
for octet in descriptor_bytes:
body.write(octet, 8)
body_bytes = body.to_bytes()
# section_length counts every byte after the length field, incl CRC.
section_length = len(body_bytes) + 4
header = BitWriter()
header.write(0xFC, 8) # table_id
header.flag(False) # section_syntax_indicator
header.flag(False) # private_indicator
header.write(0x3, 2) # sap_type = unspecified
header.write(section_length, 12)
section = header.to_bytes() + body_bytes
crc = mpeg_crc32(section)
section += crc.to_bytes(4, "big")
return section.hex().upper()
class Scte35Encoder:
"""Converts a validated AvailTrigger into an on-the-wire cue message."""
def __init__(self, base_pcr_ticks: int = 0) -> None:
# base_pcr_ticks anchors UTC-relative offsets onto the program clock.
self._base = base_pcr_ticks
def _pts_for(self, trigger: AvailTrigger, epoch_utc: datetime) -> int:
# Offset from a known program-clock anchor, clamped to 33 bits.
offset_ms = int((trigger.start_utc - epoch_utc).total_seconds() * 1000)
return (self._base + ms_to_ticks(offset_ms)) & MASK_33
def build_splice_insert(
self, trigger: AvailTrigger, epoch_utc: datetime, splice_event_id: int
) -> CueMessage:
pts = self._pts_for(trigger, epoch_utc)
event = SpliceEvent(
splice_event_id=splice_event_id,
out_of_network=trigger.out_of_network,
pts_time=pts,
break_duration=ms_to_ticks(trigger.duration_ms),
auto_return=True,
unique_program_id=trigger.unique_program_id,
avail_num=trigger.avail_num,
)
cue = CueMessage(splice_event=event)
_log(logging.INFO, trigger.spot_id,
f"splice_insert avail={trigger.avail_id} "
f"event_id={splice_event_id} pts={pts} "
f"dur_ticks={event.break_duration}")
return cue
The encoder never touches the transport multiplexer directly. It returns a CueMessage whose to_hex() output is what an integration adapter injects on the SCTE-35 PID or hands to the automation gateway. Keeping the encode pure and side-effect-free means a re-run produces byte-identical hex — the property an audit replay depends on.
Validation & Edge Cases
Broadcast timing produces boundary conditions that a naive encoder mishandles. Each of the following is exercised by the implementation above.
- DST and naive datetimes.
pts_timeis derived from the offset betweenstart_utcand a program-clock anchor. A naive local datetime crossing a spring-forward boundary shifts that offset by an hour, so the Pydantic validator rejects naive datetimes and normalizes everything to UTC before a tick is computed. - 33-bit overflow. The 90 kHz clock wraps roughly every 26.5 hours. A
pts_timecomputed past that horizon must fail closed, not silently wrap into a valid-but-wrong timestamp that fires the splice at the wrong moment.SpliceEventrejects anypts_timeabove 2³³−1. - Sports overrun sliding the break. An overrun does not shorten a break; it moves its start. Re-arming means re-encoding the cue with a new
pts_timeunder the samesplice_event_idso the splicer supersedes rather than duplicates — the same identity discipline that keeps make-good routing idempotent when a displaced spot is re-placed. - Unbalanced segmentation pairs. A
0x34provider-placement-opportunity start with no0x35end leaves the decoder waiting to rejoin the network. Emit the start and end as a matched pair sharing onesegmentation_event_id, and validate that every start has its end before the section ships. - Zero-duration or immediate splices. A
duration_msof zero is rejected atAvailTrigger; an operationally immediate splice (breaking news) is a distinct path that setssplice_immediate_flagand omitssplice_timerather than encoding a boguspts_timeof zero.
Integration Points
Upstream, the encoder consumes an AvailTrigger that the automation layer derives from the schedule — the full mapping of scheduled avails onto operator triggers is covered in Mapping Spot Schedules to SCTE-104 Triggers. The automation system speaks SCTE-104 to the splicer; the splicer is the device that converts an SCTE-104 splice_request_data operation into the SCTE-35 section this module encodes. That conversion boundary is the one place where operator intent becomes stream-timed binary.
Downstream, the emitted cue is described by a small, explicit wire envelope so an SSAI or ad-decision consumer and the reconciliation pipeline both join on the same identity:
{
"message_type": "scte35.cue.v1",
"avail_id": "AV-2026-0717-1930-03",
"splice_event_id": 100347,
"command": "splice_insert",
"out_of_network": true,
"pts_time_ticks": 2189700000,
"break_duration_ticks": 2700000,
"section_hex": "FC302500000000000000FFF01405...",
"issued_at": "2026-07-17T19:29:57Z"
}
The splice_event_id is the join key. A superseding cue (an overrun re-arm) reuses it with a higher pts_time; the splicer applies the latest and the reconciliation engine matches the aired section back to the avail on that same id. This is what lets as-run reconciliation prove a scheduled avail actually fired, and it keeps the whole chain consistent with the contracts defined in Broadcast Traffic Architecture & Taxonomy.
Figure — Conversion handshake: automation sends an SCTE-104 splice_request_data, the splicer computes the 90 kHz pts_time and injects an SCTE-35 section on the stream PID, and the aired cue is logged for reconciliation.
Compliance & Audit Considerations
Cue signaling is compliance-critical because a mistimed or unlogged splice can misstate what aired, and what aired is what bills. Three obligations apply directly.
Provable airing. Every cue emitted carries a deterministic splice_event_id tied to its avail_id, and every emission is logged on the timestamp | level | module | spot_id line and shipped to append-only storage. When a break is challenged, the immutable log plus the captured as-run section reconstruct exactly which avail fired and when — the evidentiary chain a billing dispute or an FCC public-inspection review depends on.
Sponsorship identification survives the splice. SCTE-35 controls where a local spot goes, not what it is, but the two must stay joined: the segment that airs in a provider placement opportunity must carry the same sponsorship-ID clearance the order requires. The engine never fabricates a UPID; it carries the identifier the schedule supplied so downstream systems can prove sponsorship attribution.
Reconciliation is not optional. A cue that fires but never reconciles against the schedule is a discrepancy, and unreconciled discrepancies are how revenue leaks. Keeping the encoder pure — trigger in, hex out, no hidden mutation — means the CueMessage is the single source of truth that as-run reconciliation joins on, with no encoder-side state that could drift from the log.
Troubleshooting & Common Errors
| Error code | Root cause | Remediation |
|---|---|---|
ERR_PTS_OVERFLOW |
Computed pts_time exceeds the 33-bit field (clock wrapped past ~26.5h) |
Re-anchor the encoder’s base_pcr_ticks to the current program clock; never let the value wrap silently into a valid-but-wrong timestamp |
ERR_UNBALANCED_SEGMENT |
A placement-opportunity start (0x34/0x36) shipped without its matching end |
Emit start and end as a pair sharing one segmentation_event_id; validate pairing before the section is injected |
ERR_NAIVE_DATETIME |
start_utc arrived without timezone info |
Reject at ingestion and normalize to UTC upstream; a naive datetime across DST shifts the splice by an hour |
ERR_CRC_MISMATCH |
Injected section fails the decoder’s CRC-32 check | Recompute with the MPEG Annex B parameters (init 0xFFFFFFFF, no reflection, no final XOR); a wrong CRC makes the whole cue invisible to the decoder |
ERR_DUPLICATE_EVENT_ID |
Two live avails share a splice_event_id |
Allocate ids from a monotonic per-channel sequence; reuse an id only to deliberately supersede a prior cue (an overrun re-arm) |
The unbalanced-segment case deserves particular attention. A missing end descriptor does not throw at the encoder; it strands the stream in the avail because the decoder never receives its rejoin cue. Treat start/end pairing as a hard gate, the same way make-good routing treats an open recovery — a break you enter but never exit is worse than one you never signalled.
Related
- Spot Scheduling Validation & Rule Engines — the parent architecture that defines the taxonomy, contracts, and idempotency guarantees this encoder inherits.
- How to Insert SCTE-35 Splice Points in Python — the bit-level construction of a
splice_insertcue, from timing math to a serialized section. - Mapping Spot Schedules to SCTE-104 Triggers — the upstream automation path that turns scheduled avails into the operator triggers a splicer converts.
- As-Run Reconciliation and Discrepancy Handling — how an aired cue is matched back to its scheduled avail so billing reflects what actually fired.