DMCA.lzdevs

API & webhooks

Two ways to get notices into your own systems: we push signed webhook events, or you poll the REST API with a cursor. Both only cover domains an admin has approved for your account.

Authentication

Create a key on the API keys page and send it as a bearer token. Base URL: https://dmca.lzdevs.com/api/v1. Responses are JSON; errors look like {"error": "code", "message": "…"}. Limit: 600 requests a minute per account.

curl -H "Authorization: Bearer dmca_…" https://dmca.lzdevs.com/api/v1/domains

Endpoints

RequestWhat it does
GET /meThe account the key belongs to.
GET /datasetWhich import of Google’s dataset is being served and when Google published it.
GET /domainsYour domains with status and totals (notices, URLs removed / not in index / no action / pending, listed URLs).
POST /domainsAdd a domain for review. Body: {"domain": "example.com", "note": "optional"}.
GET /notices?domain=…Notices naming the domain (and its subdomains). Filters below; page + limit (≤100). Or after=<cursor> for polling.
GET /notices/{id}?domain=…One notice, with the URLs Google listed for your domain.
GET /urls?domain=…Individually listed URLs (Google lists only URLs it took no action on). Filters below; page + limit (≤1000).
GET /domains/{domain}/noticesSame as /notices?domain=.
GET /domains/{domain}/urlsSame as /urls?domain=.
GET /webhooksYour webhooks, including signing secrets.
POST /webhooksBody: {"url": "https://…", "events": ["notice.created"], "domain": null}. Returns the webhook with its secret.
POST /webhooks/{id}/testQueue a ping event.
DELETE /webhooks/{id}Delete a webhook.

Filters

ParameterMeaning
from, toNotice date range, YYYY-MM-DD (UTC, inclusive).
owner, reporterPart of the copyright owner’s or reporting organization’s name.
abuseronly or exclude notices Google flagged as coming from someone abusing the process.
statusNotices only: removed, not_in_index, no_action or pending — at least one such URL on your domain.
qNotices: a notice ID. URLs: text the URL contains.
requestURLs only: URLs of one notice.
added=latestOnly rows that arrived in the most recent daily update.
sortnew (default), old, or for notices urls (most URLs on your domain).

Polling without missing anything

Every notice carries a cursor made of the import it first arrived in and its ID. Pass after and you get notices that arrived after that point, oldest first, plus a next_cursor to store. If has_more is true, call again straight away. When nothing is new you get the same cursor back.

# First run: start from 0 and walk forward. Store next_cursor.
curl -H "Authorization: Bearer $KEY" "https://dmca.lzdevs.com/api/v1/notices?domain=example.com&after=0&limit=100"

# Once a day (after the dataset update): ask for anything newer than the stored cursor.
curl -H "Authorization: Bearer $KEY" "https://dmca.lzdevs.com/api/v1/notices?domain=example.com&after=3.42552478"

# Filtered lists, same filters as the panel:
curl -H "Authorization: Bearer $KEY" "https://dmca.lzdevs.com/api/v1/urls?domain=example.com&from=2026-01-01&owner=Naver&q=/chapter"

The data is a mirror of Google’s published dataset, updated once a day, so polling more often than daily only returns the same cursor.

The notice object

{
  "id": "42552478",
  "domain": "example.com",
  "host": "example.com",
  "date": "2026-03-24T06:43:01.000Z",
  "copyright_owner": { "id": 209063, "name": "Naver" },
  "reporter": { "id": 127512, "name": "comeso GmbH" },
  "from_abuser": false,
  "domain_urls": { "removed": 0, "not_in_index": 1, "no_action": 0, "pending": 0 },
  "request_urls": { "removed": 235, "not_in_index": 261, "no_action": 0, "pending": 0 },
  "lumen_url": "https://lumendatabase.org/notices/81269671",
  "google_url": "https://transparencyreport.google.com/copyright/requests/42552478",
  "first_import": 3,
  "cursor": "3.42552478"
}

domain_urls counts the URLs on your domain; request_urls counts the whole request, which often names many domains. host is the domain as Google recorded it (a subdomain of yours shows up here). lumen_url is the full notice on the Lumen Database. GET /notices/{id} and webhook events also include urls: the URLs Google listed for your domain.

The URL object

{
  "url": "https://example.com/series/123/chapter-4",
  "host": "example.com",
  "domain": "example.com",
  "notice_id": "45250150",
  "date": "2026-03-24T05:32:27.000Z",
  "copyright_owner": "Kakaopage",
  "reporter": "comeso GmbH",
  "from_abuser": false,
  "first_import": 1
}

Webhooks

Add one on the Webhooks page or with the API. Each event is a POST with a JSON body. Answer with any 2xx within 10 seconds; anything else (including redirects) is retried after 1 min, 5 min, 30 min, 2 h, 6 h and 12 h. Use X-DMCA-Delivery to ignore duplicates.

EventWhen
notice.createdA new DMCA notice names one of your domains (sent after each daily dataset update).
domain.updatedA daily update added notices or URLs for one of your domains (one summary per domain).
domain.approvedAn admin approved one of your domains.
domain.rejectedAn admin rejected one of your domains.
pingYou pressed “Send test”. Always sent, whatever events you picked.

Events for new notices go out after each daily update of the dataset. Notices that existed before your domain was approved don’t fire notice.created — they’re already in the panel and the API. A single update sends at most 500 notice.created events per domain; domain.updated always carries the full counts.

POST /your/endpoint HTTP/1.1
Content-Type: application/json
X-DMCA-Event: notice.created
X-DMCA-Delivery: evt_Yb3...
X-DMCA-Timestamp: 1774343412
X-DMCA-Signature: sha256=5d1c...

{
  "id": "evt_Yb3...",
  "event": "notice.created",
  "created_at": "2026-03-24T09:00:12.000Z",
  "data": {
    "domain": "example.com",
    "notice": { ...same shape as the API's notice object... }
  }
}

Verifying signatures

The signature is an HMAC-SHA256 of timestamp + "." + raw body with your webhook’s secret. Reject requests whose timestamp is more than five minutes old.

import crypto from 'node:crypto';

// Use the raw request body, before any JSON parsing.
function verify(rawBody, headers, secret) {
  const ts = headers['x-dmca-timestamp'];
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // replay window
  const expected = 'sha256=' + crypto.createHmac('sha256', secret)
    .update(ts + '.' + rawBody).digest('hex');
  const got = headers['x-dmca-signature'] || '';
  return got.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected));
}

PHP

$ts  = $_SERVER['HTTP_X_DMCA_TIMESTAMP'];
$body = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $body, $secret);
if (!hash_equals($expected, $_SERVER['HTTP_X_DMCA_SIGNATURE'] ?? '') || abs(time() - (int)$ts) > 300) {
  http_response_code(400); exit;
}

Python

import hmac, hashlib, time

def verify(raw_body: bytes, headers, secret: str) -> bool:
    ts = headers["X-DMCA-Timestamp"]
    if abs(time.time() - int(ts)) > 300:
        return False
    mac = hmac.new(secret.encode(), ts.encode() + b"." + raw_body, hashlib.sha256)
    return hmac.compare_digest("sha256=" + mac.hexdigest(), headers.get("X-DMCA-Signature", ""))

Where the data comes from

Everything is served from a local copy of Google’s Transparency Report dataset for Web Search copyright removals (all requests since 2011). It is downloaded and diffed every day; whatever changed since the previous day is tagged with that day’s import, which is what added=latest, cursors and webhooks use.

Google lists individual URLs only for URLs it took no action on. Removed, not-indexed and pending URLs are published as counts per notice and per domain; the full list is on the notice’s Lumen page. Requests sent only to your host, your registrar or other search engines are not in this data.