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
| Request | What it does |
|---|---|
GET /me | The account the key belongs to. |
GET /dataset | Which import of Google’s dataset is being served and when Google published it. |
GET /domains | Your domains with status and totals (notices, URLs removed / not in index / no action / pending, listed URLs). |
POST /domains | Add 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}/notices | Same as /notices?domain=. |
GET /domains/{domain}/urls | Same as /urls?domain=. |
GET /webhooks | Your webhooks, including signing secrets. |
POST /webhooks | Body: {"url": "https://…", "events": ["notice.created"], "domain": null}. Returns the webhook with its secret. |
POST /webhooks/{id}/test | Queue a ping event. |
DELETE /webhooks/{id} | Delete a webhook. |
Filters
| Parameter | Meaning |
|---|---|
from, to | Notice date range, YYYY-MM-DD (UTC, inclusive). |
owner, reporter | Part of the copyright owner’s or reporting organization’s name. |
abuser | only or exclude notices Google flagged as coming from someone abusing the process. |
status | Notices only: removed, not_in_index, no_action or pending — at least one such URL on your domain. |
q | Notices: a notice ID. URLs: text the URL contains. |
request | URLs only: URLs of one notice. |
added=latest | Only rows that arrived in the most recent daily update. |
sort | new (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.
| Event | When |
|---|---|
notice.created | A new DMCA notice names one of your domains (sent after each daily dataset update). |
domain.updated | A daily update added notices or URLs for one of your domains (one summary per domain). |
domain.approved | An admin approved one of your domains. |
domain.rejected | An admin rejected one of your domains. |
ping | You 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.