---
title: REST API — Deep Linking
menu_group: REST API
menu_order: 8
tab: Deep Linking
tab_order: 8
summary: Resolve Roku deep-link content IDs to playable items and series navigation behavior.
---

# REST API — Deep Linking

MediaBlaster owns the canonical **numeric content-ID contract** for Roku deep links, Roku Search `playId`, and other connected apps. Resolution is public and does not depend on the Roku License Auth addon.

## Content ID contract

- **Canonical `contentId`:** WordPress post ID as a **string** (for example `"456"`).
- **Episodic content:** always use the **episode post ID**, even when the requested Roku `mediaType` is `episode`, `series`, or `season`. The requested media type tells the app whether to play immediately or open series/season UI.
- **Legacy fallback:** a **series** post ID is accepted only for `mediaType=series`. The API resolves it to a representative published episode and returns that episode ID as `content_id`. Do not advertise series post IDs as the preferred format.

The same numeric ID should later be used as Roku Search `playId`.

## Supported media types

| WordPress post type | Default media type | Supported media types |
|-------------------|-------------------|----------------------|
| `movies` | `movie` | `movie` |
| `videos` | `shortFormVideo` | `shortFormVideo` |
| `episodes` | `episode` | `episode`, `series`, `season` |
| `series` | `series` | `series` (legacy input only) |

Not supported: `liveFeed`, `sportsEvent`, `tvSpecial` (no matching post types in MediaBlaster).

Media types are matched **case-insensitively** and normalized to Roku canonical casing in responses.

## Endpoint

```
GET /wp-json/mediablaster/v3/deep-link/{content_id}?media_type={type}
```

| Property | Value |
|----------|-------|
| Auth | None (public) |
| Method | GET |
| Path `content_id` | Numeric WordPress post ID |
| Query `media_type` or `mediaType` | **Required.** One of the supported types above |

Discovery index includes `links.deep_link` (base URL; append `/{content_id}?media_type=…`).

### Example requests

```http
GET /wp-json/mediablaster/v3/deep-link/123?media_type=movie
GET /wp-json/mediablaster/v3/deep-link/234?mediaType=shortFormVideo
GET /wp-json/mediablaster/v3/deep-link/456?media_type=episode
GET /wp-json/mediablaster/v3/deep-link/456?media_type=series
GET /wp-json/mediablaster/v3/deep-link/456?media_type=season
GET /wp-json/mediablaster/v3/deep-link/789?media_type=series
```

The last example uses a legacy **series** post ID (`789`) and resolves to a representative episode.

## Successful response

```json
{
  "content_id": "456",
  "requested_media_type": "series",
  "behavior": "series",
  "item": { "...standard MediaBlaster content object..." },
  "series": {
    "id": "789",
    "title": "Example Series",
    "type": "seasons",
    "requested_episode_id": "456",
    "season_number": 1,
    "episode_number": 3
  },
  "supported_media_types": ["episode", "series", "season"]
}
```

| Field | Description |
|-------|-------------|
| `content_id` | Canonical content ID string (always the episode ID for episodic deep links) |
| `requested_media_type` | Canonical Roku casing |
| `behavior` | `play`, `series`, or `season` — tells the app what UI/navigation to run |
| `item` | Standard serialized content for the playable post |
| `series` | Parent series context for episodic requests; `null` for movies and short videos |
| `supported_media_types` | Types valid for the resolved playable item |

### Behavior mapping

| Requested media type | `behavior` | `item` post |
|---------------------|------------|-------------|
| `movie` | `play` | Movie |
| `shortFormVideo` | `play` | Video |
| `episode` | `play` | Episode |
| `series` | `series` | Episode (from content ID or legacy series fallback) |
| `season` | `season` | Episode |

For `movie`, `episode`, and `shortFormVideo`, `series` is `null`.

## Content object `deep_link` metadata

Every item from `GET /content` (and type aliases) includes a top-level `deep_link` object built by the same resolver service:

**Movie:**

```json
"deep_link": {
  "content_id": "123",
  "default_media_type": "movie",
  "supported_media_types": ["movie"]
}
```

**Short video:**

```json
"deep_link": {
  "content_id": "234",
  "default_media_type": "shortFormVideo",
  "supported_media_types": ["shortFormVideo"]
}
```

**Episode:**

```json
"deep_link": {
  "content_id": "456",
  "default_media_type": "episode",
  "supported_media_types": ["episode", "series", "season"]
}
```

**Series** (when a representative episode exists):

```json
"deep_link": {
  "content_id": "456",
  "default_media_type": "series",
  "supported_media_types": ["series"],
  "source_series_id": "789"
}
```

When a series has no valid published episode, `content_id` is `null` and `supported_media_types` is `[]`.

## Subscription and availability

Deep-link resolution enforces the same visibility rules as `GET /content/{id}`:

- **Draft / non-published** content returns **403** `mediablaster_forbidden` for public viewers (editors with `edit_post` may still resolve).
- **Hide completely** subscription behavior returns **403**.
- **Locked but visible** content returns **200**; `item.media.url` is `null` when the user lacks access (no URL leakage).
- **Availability window:** `availability.start_date` / `availability.end_date` (`Y-m-d` meta) are compared to the site timezone date (`current_time('Y-m-d')`). Outside the window returns **403**.

## Error responses

| Code | HTTP | When |
|------|------|------|
| `mediablaster_invalid_payload` | 400 | Missing or invalid `content_id`; missing `media_type` / `mediaType` |
| `mediablaster_invalid_media_type` | 400 | Unsupported media type |
| `mediablaster_not_found` | 404 | Unknown post ID; post type does not support requested media type; episode without parent series (`series`/`season`); series with no published episodes |
| `mediablaster_forbidden` | 403 | Unpublished, hidden, or outside availability window |

## Extensibility filters

| Filter | Arguments | Purpose |
|--------|-----------|---------|
| `mediablaster_deep_link_content_id` | `$content_id`, `$post` | Override canonical content ID string |
| `mediablaster_deep_link_supported_media_types` | `$types`, `$post` | Override supported media types for a post |
| `mediablaster_deep_link_response` | `$response`, `$resolved_post`, `$requested_media_type`, `$request` | Modify the final REST response |

## Representative episode selection

When a legacy series post ID is supplied, MediaBlaster picks the **first published episode** in `rovidx_smarttv_playlist` order that passes public deep-link availability (visibility + date window). Playlist order is author-defined in the series editor.

## Manual verification

No automated test framework ships with the plugin. After deployment, verify locally:

```powershell
# Route registration
wp eval "do_action('rest_api_init'); `$routes = rest_get_server()->get_routes(); echo isset(`$routes['/mediablaster/v3/deep-link/(?P<content_id>\d+)']) ? 'deep-link-route-ok' : 'deep-link-route-missing'; echo PHP_EOL;" --path="c:\Users\rob\Local Sites\wp-smart-tv\app\public"

# Discovery link
curl -s "http://wp-smart-tv.local/wp-json/mediablaster/v3" | jq '.links.deep_link'
```

Replace post IDs with published content on your install:

| # | Test | Expected |
|---|------|----------|
| 1 | `GET /deep-link/{movie_id}?media_type=movie` | 200, `behavior: play`, `series: null` |
| 2 | `GET /deep-link/{video_id}?media_type=shortFormVideo` | 200, `behavior: play` |
| 3 | `GET /deep-link/{episode_id}?media_type=episode` | 200, `behavior: play`, `content_id` = episode ID |
| 4 | Same episode ID + `media_type=series` | 200, `behavior: series`, `series` object populated |
| 5 | Same episode ID + `media_type=season` | 200, `behavior: season` |
| 6 | `GET /deep-link/{series_id}?media_type=series` | 200, `content_id` = representative episode ID |
| 7 | Movie ID + `media_type=episode` | 404 |
| 8 | `GET /deep-link/999999?media_type=movie` | 404 |
| 9 | Draft post ID | 403 |
| 10 | Locked (visible) post without auth | 200, `item.media.url: null` |
| 11 | Post with `end_date` in the past | 403 |
| 12 | Series with empty playlist + `media_type=series` | 404 |
| 13 | `GET /content`, `/movies`, `/videos`, `/episodes`, `/series` | Still 200 (unchanged) |

## Related guides

- [REST API Overview](rest-api-overview.md)
- [Content API and Fields](rest-api-content.md)
