AdultEmbed API

v1 · REST · JSON · open

Overview

The AdultEmbed API is a free, continuously updated database of embeddable adult videos: titles, descriptions, WebP thumbnails, durations, categories, tags, performers and studios. Every video comes with a permanent player URL on adultembed.com, so you never handle or store a video file or a host link.

Open API: no signup, no key. Every content, sync, feed and embed endpoint answers anonymous requests. A free key only unlocks account features (your stats, your channels).

Base URL

https://adultembed.com/api/

Responses are JSON (UTF-8) unless a feed format says otherwise. Dates are ISO 8601 UTC. Slugs are lowercase and hyphenated, and they are what every filter takes. Only videos that are approved and currently playable are ever returned. A video that stops playing disappears from every endpoint and is announced as deleted in the changelog.

Quickstart

Fetch the ten newest videos and print an embed for each. Click Try it to run the request live from this page.

GET/api/database/browse/?page_size=10


curl -s "https://adultembed.com/api/database/browse/?page_size=10" \
  -H "X-Site-URL: https://your-site.com"
<?php
$ctx  = stream_context_create(['http' => ['header' => "X-Site-URL: https://your-site.com\r\n"]]);
$data = json_decode(file_get_contents('https://adultembed.com/api/database/browse/?page_size=10', false, $ctx), true);

foreach ($data['results'] as $v) {
    $player = 'https://adultembed.com/player/embed/' . $v['id'] . '/';
    printf('<h3>%s</h3><iframe src="%s" width="640" height="360" allowfullscreen></iframe>',
           htmlspecialchars($v['title']), $player);
}
const res  = await fetch('https://adultembed.com/api/database/browse/?page_size=10',
                         { headers: { 'X-Site-URL': 'https://your-site.com' } });
const data = await res.json();

for (const v of data.results) {
  console.log(v.title, `https://adultembed.com/player/embed/${v.id}/`);
}
import requests

r = requests.get("https://adultembed.com/api/database/browse/",
                 params={"page_size": 10},
                 headers={"X-Site-URL": "https://your-site.com"}, timeout=30)
for v in r.json()["results"]:
    print(v["title"], f"https://adultembed.com/player/embed/{v['id']}/")

Site & key headers

Nothing is required. Two optional headers make your integration better:

HeaderWhat it does
X-Site-URLYour site, e.g. https://your-site.com. Signed embeds are bound to it and your usage is reported per site. Browsers send Origin/Referer on their own; server-side code should send this header.
X-API-KeyA free key (issued instantly at adultembed.com). Adds per-key statistics and is required only for /api/stats/, channels and webhook deliveries. A missing or wrong key never blocks a content request.

Caching, limits, errors

There is no request quota. Responses are cached server-side, so polling faster than the cache refresh returns the same answer:

EndpointFresh every
/api/database/browse/10 minutes per unique query string
/api/feeds/…10 minutes per unique query string
/api/videos/<id>/2 minutes
/api/changelog/1 minute (since is rounded to the minute)
/api/feeds/dump/…regenerated nightly

The one hard limit: POST /api/videos/<id>/get-embed/ allows 60 calls per IP per minute, plenty for a page of 24 cards, but not for bulk harvesting. Use the plain player URL when you don't need a signed one.

StatusMeaning
400Bad parameter, e.g. changelog without since, or videos/status without ids. The body says which.
403 / 401An account endpoint (stats, channels, webhook deliveries) without a valid X-API-Key.
404Not found or no longer playable. Treat it like a deleted changelog entry.
429get-embed rate limit. Retry after a minute.

Browse & filter

GET/api/database/browse/open

The main listing. Every list filter takes one slug or several comma-separated slugs. Several slugs mean any of, and different filters combine with and.

ParamValuesNotes
orientationstraight · trans · gay · bi · allDefaults to straight on browse. Use all (or mixed) for everything. A gay studio or performer returns 0 videos unless you pass orientation=gay or all.
categoriesslugse.g. big-tits,blonde
tagsslugse.g. blowjob,creampie
performersslugse.g. mannie-coco
studiosslugse.g. blacked,brazzers
content_typescene · amateurscene = matched to a studio release
source_typeembed · direct · hlsHow the player streams it (all play in our iframe)
searchtextSubstring match on title and description
sortnewest · popular · longest · views · likesDefault newest (also accepted: published, trending)
page, page_size1…, 1–100Default page size 20. per_page works as an alias.
GET /api/database/browse/?orientation=trans&sort=views&page_size=50
GET /api/database/browse/?studios=blacked&tags=creampie
GET /api/database/browse/?performers=mannie-coco&content_type=scene
GET /api/database/browse/?orientation=mixed&search=massage
Never hard-code slugs: categories, tags, performers and studios change every day. Fetch them from the lists and use the filter value each entry gives you.

Response

{
  "count": 410,
  "next": "https://adultembed.com/api/database/browse/?page=2&studios=blacked",
  "previous": null,
  "results": [
    {
      "id": 3579910,
      "title": "Tiny Beauty Can Barely Fit His Cock",
      "slug": "blacked250113emirimomotatinybeautycanbarelyfithiscockxxx",
      "description": "Emiri has a crazy cuckolding kink, but it's too hot for some guys to handle. …",
      "thumbnail": "https://adultembed.com/media/thumbnails/webp/pz-3579910.webp",
      "duration": "00:39:42",
      "duration_seconds": 2382.0,
      "resolution": "",
      "view_count": 9, "like_count": 0, "dislike_count": 0, "rating": 0,
      "content_type": "scene",
      "categories": [],
      "tags": [ {"id": 1110, "name": "Blowjob", "slug": "blowjob", "video_count": 48591}, … ],
      "performers": [ {"id": 2682, "name": "Mannie Coco", "slug": "mannie-coco", "bio": "…"}, … ],
      "studio": {"id": 344, "name": "Blacked", "slug": "blacked", "network_name": "Vixen Media Group"},
      "tpdb_match_status": "matched",
      "tpdb_rating": 4.5,
      "tpdb_background_url": "https://cdn.theporndb.net/scene/…",
      "available_hosts": ["Doodstream"],
      "primary_source_type": "embed",
      "published_at": "2026-07-20T00:43:44.627422Z"
    }
  ]
}
Browse never returns a video URL. Build the player link from id (see Player iframe). duration_seconds is 0 and duration is null while a fresh video's length is still being measured.

Lists: categories, tags, performers, studios

Everything you can filter by is a live list. Fetch it, show it to your visitors (menus, tag clouds, A–Z performer pages, studio directories), and pass the chosen entry's filter straight to browse. Nothing here is static: new tags, performers and studios appear daily and the counts are recalculated every night from the videos that actually play.

GET/api/taxonomy/categories/open

GET/api/taxonomy/tags/open

GET/api/taxonomy/performers/open

GET/api/taxonomy/studios/open

ParamValuesDefaultNotes
searchtext–Case-insensitive part of the name: search=big
starts_witha–z · 0-9–For A–Z index pages
min_videos0…1Hide entries with fewer playable videos; 0 lists empty ones too
orderingpopular · name · newestpopularpopular = most videos first; newest = most recently added
page, per_page1…, 1–5001, 100Follow next until it is null

Response

{
  "list": "tags", "count": 122, "page": 1, "per_page": 100, "pages": 2,
  "next": "https://adultembed.com/api/taxonomy/tags/?search=anal&page=2", "previous": null,
  "filter_param": "tags",
  "results": [
    {"id": 1099, "name": "Anal", "slug": "anal", "video_count": 26070,
     "filter": "tags=anal",
     "videos_url": "https://adultembed.com/api/database/browse/?tags=anal"}
  ]
}

Performers also carry verified and image; studios carry logo and network. video_count counts every orientation, so add orientation= to the browse call for the audience you serve (browse defaults to straight).

Recipe: a menu that stays current

// 1. the 30 biggest tags, refreshed whenever you like (they are cached 10 min on our side)
const tags = await (await fetch('https://adultembed.com/api/taxonomy/tags/?per_page=30')).json();
// 2. a visitor picks one -> use its ready-made filter
const pick = tags.results[0];
const vids = await (await fetch(`https://adultembed.com/api/database/browse/?${pick.filter}&orientation=all&sort=newest`)).json();
// 3. every performer, page by page
let url = 'https://adultembed.com/api/taxonomy/performers/?per_page=500', all = [];
while (url) { const p = await (await fetch(url)).json(); all.push(...p.results); url = p.next; }
<?php
$base = 'https://adultembed.com';
$cats = json_decode(file_get_contents("$base/api/taxonomy/categories/?per_page=50"), true);
foreach ($cats['results'] as $c) {
    echo '<a href="/c/' . $c['slug'] . '">' . htmlspecialchars($c['name']) . ' (' . $c['video_count'] . ')</a>';
}
// on /c/{slug}: $videos = json_decode(file_get_contents("$base/api/database/browse/?categories=$slug&orientation=all"), true);
import requests
base = 'https://adultembed.com'
studios, url = [], f'{base}/api/taxonomy/studios/?per_page=500&min_videos=5'
while url:
    page = requests.get(url, timeout=30).json()
    studios += page['results']
    url = page['next']
videos = requests.get(f"{base}/api/database/browse/?{studios[0]['filter']}&orientation=all").json()

Bulk snapshot

GET/api/database/metadata/open

One call with the biggest categories, tags and performers together (type = all · categories · tags · performers, limit up to 10000, default 2000). Handy for a one-off import; for anything that grows, use the paginated lists above.

Video detail

GET/api/videos/<id>/open

One video, same fields as a browse result. 404 means the video is no longer playable, so remove it.

Video status (bulk)

GET/api/videos/status/?ids=1,2,3open

Which of up to 500 videos are playable right now. Run it once a day over your whole library: unpublish unavailable and republish anything that came back. This catches removals that never reach the changelog.

{"servable": [3579910], "unavailable": [1]}

Player iframe

Every video plays at a permanent URL. This is all most sites need:

<iframe src="https://adultembed.com/player/embed/3579910/"
        width="640" height="360" frameborder="0" scrolling="no"
        allow="autoplay; fullscreen" allowfullscreen></iframe>

By slug instead of id: https://adultembed.com/player/embed/v/<slug>/

ParamDefaultEffect
autoplay01 starts on load (browsers require muted=1 too)
muted01 starts muted
user_vast—URL-encoded VAST tag: your own pre-roll ad

The player is responsive. For a 16:9 box that scales, wrap it: <div style="position:relative;padding-top:56.25%"> and give the iframe position:absolute;inset:0;width:100%;height:100%.

Signed embed URL

POST/api/videos/<id>/get-embed/open

Returns a player URL signed for your domain (from X-Site-URL, otherwise Origin/Referer) and valid for 2 hours. Plays are then attributed to your site and key. Note that this is a POST.

curl -s -X POST "https://adultembed.com/api/videos/3579910/get-embed/" \
  -H "X-Site-URL: https://your-site.com"
{
  "player_url": "https://adultembed.com/player/embed/3579910/?sig=d6809d…&exp=1791292288&d=your-site.com&k=0",
  "video_id": 3579910,
  "title": "Tiny Beauty Can Barely Fit His Cock",
  "embed_code": "<iframe src=\"…&autoplay=1\" width=\"100%\" height=\"100%\" frameborder=\"0\" allowfullscreen allow=\"autoplay; encrypted-media\"></iframe>"
}
Request signed URLs when the page renders; don't store them, because they expire after 2 hours. To store something permanent, use the plain player URL.

oEmbed

GET/api/oembed/?url=<player URL>open

Standard oEmbed 1.0 (type: video). Optional maxwidth (≤ 1280) and maxheight. Forum software, Ghost, Discourse, Iframely and Embedly can turn a pasted player link into the player. Our WordPress plugin registers the provider for you.

Changelog

GET/api/changelog/?since=<ISO 8601>open

Everything that changed since a moment, one entry per video (its latest change), oldest first. Accepts the same filters as browse (orientation, categories, studios …), so a niche site is only told about videos it wants. Unlike browse, orientation is not filtered unless you pass it.

change_typeWhat to do
createdFetch /api/videos/<id>/ and insert it
updatedFetch and upsert (title, tags, thumbnail or source changed, or the video came back)
deletedUnpublish it. Always sent whatever your filters are; deleting what you don't have is a no-op.
{
  "changes": [
    {"video_id": 7052926, "change_type": "deleted",
     "change_data": {"changes": {"title": {"old": "…", "new": "…"}}},
     "timestamp": "2026-10-06T11:01:20.049309+00:00"}
  ],
  "results": [ …same list… ],
  "count": 1,
  "has_more": false,
  "next_since": "2026-10-06T11:21:04.102938+00:00"
}

At most 5000 changes per call. If has_more is true, call again immediately with since=next_since.

Sync recipe

  1. Seed once from a nightly dump (or page through feeds with limit=10000).
  2. Every 5–15 minutes, call the changelog with the next_since you saved last time.
  3. Once a day, check your whole library with /api/videos/status/ in batches of 500.
import requests, time
API = "https://adultembed.com/api"
H = {"X-Site-URL": "https://your-site.com"}
since = load_cursor() or "2026-10-01T00:00:00Z"

while True:
    d = requests.get(f"{API}/changelog/", params={"since": since, "orientation": "trans"}, headers=H, timeout=60).json()
    for ch in d["changes"]:
        if ch["change_type"] == "deleted":
            unpublish(ch["video_id"])
        else:
            r = requests.get(f"{API}/videos/{ch['video_id']}/", headers=H, timeout=30)
            upsert(r.json()) if r.ok else unpublish(ch["video_id"])
    since = d["next_since"]; save_cursor(since)
    if not d["has_more"]:
        time.sleep(600)

Webhooks

Push delivery (a JSON POST to your URL on video_added / video_updated) is available on request. Email [email protected] with your key and endpoint. For almost every site the changelog is simpler and can't miss an event while your server is down, so start there.

With a key, you can inspect and retry deliveries:

GET  /api/webhooks/deliveries/?status=failed&limit=50      X-API-Key: your_key
POST /api/webhooks/deliveries/<delivery_id>/retry/          X-API-Key: your_key

Feeds (CSV, RSS, JSON)

GET/api/feeds/videos.{csv|xml|json}open

Import-ready feeds for tube scripts, newest first. They take every browse filter (no orientation default) plus since=<ISO 8601> for incremental imports. Page with page or offset; limit defaults to 1000 (max 10000).

URLFormatWorks with
videos.csv?layout=pornhubPipe-separated, no header:
embed|thumb|thumbs|title|tags|categories|pornstars|duration|views|likes|dislikes
AVS, SmartCJ, TubeAce, Mechbunny, PHPVibe "Pornhub dump" presets
videos.csv?layout=genericHeader row; sep=, ; | or tabKVS, AVS column mapping, spreadsheets
videos.xmlMedia RSS 2.0 (media:player, media:thumbnail, keywords, categories)KVS RSS import, WP RSS Aggregator, WPeMatico, WP All Import
videos.jsonresults + next_offsetCustom code
https://adultembed.com/api/feeds/videos.csv?layout=pornhub&orientation=trans&limit=5000
https://adultembed.com/api/feeds/videos.xml?categories=amateur&since=2026-10-01T00:00:00Z

Embed codes in feeds use the plain player URL, which never expires. Plays are attributed to your site through the Referer.

Nightly dumps

GET/api/feeds/dumps.jsonopen

The whole playable database as gzipped CSV, rebuilt every night. Stable links (they redirect to the newest file):

https://adultembed.com/api/feeds/dump/<orientation>.<layout>.csv.gz
  orientation: all · straight · trans · gay · bi
  layout:      pornhub · generic

OpenAPI

A machine-readable OpenAPI 3.1 description lives at /api/openapi.json. Load it into Postman, Insomnia or Swagger UI, or generate a client with openapi-generator.

Your stats

GET/api/stats/X-API-Key

Usage totals for your key. Per-site traffic is also tracked for keyless use. Ask us if you want your numbers.

Channels

A channel is a saved filter with a public, stable feed URL that keeps updating as matching videos arrive.

GET/api/channels/X-API-Key
POST/api/channels/X-API-Key
curl -s -X POST "https://adultembed.com/api/channels/" \
  -H "X-API-Key: your_key" -H "Content-Type: application/json" \
  -d '{
    "name": "Long amateur",
    "description": "Amateur, 20 minutes and up",
    "filter_category_ids": [2060],
    "filter_tag_ids": [],
    "filter_performer_ids": [],
    "filter_search": "",
    "min_duration_seconds": 1200,
    "max_duration_seconds": null,
    "sort_by": "published",
    "is_public": true
  }'

IDs come from the lists. The response includes the channel's slug.

GET/api/channels/<slug>/ PATCHDELETEX-API-Key
GET/api/channels/<slug>/videos/open if public

Same response shape and paging as browse.

View postback

POST/api/track/view/open

Only needed if you play videos in a player of your own; our iframe counts its own plays.

POST /api/track/view/
Content-Type: application/json

{"video_id": 3579910, "country": "US", "device": "mobile", "duration_watched": 180,
 "referrer": "https://your-site.com/video/123"}

Only video_id is required.

Free and open to everyone. Want per-key stats? Get a free key. Need a feed format or field we don't have yet, or found something this page gets wrong? Email 2034network@gmail.com.