Overview
A detection is one of your items, a wire story or a photo, found in one publisher article, for one
of your subscriptions. It says which item (source), which article and publisher (article), how
strong the match is (strength), and when we first saw it (first_detected_at). A detection has a
stable id and a version that goes up whenever something you can see in it changes, such as a
new score or an earlier first detection. You read detections in two ways: everything first
detected in a period (GET /v1/detections), or everything new and changed since your last call
(GET /v1/detections/changes, the change stream). Data starts on the day each subscription's
stream begins, which GET /v1/me gives as data_starts; nothing earlier is available.
All times are ISO 8601 in UTC with Z. A field a content type does not have is absent, not null
(an image has no source.language); an unknown value is null. Every answer carries an
X-Request-Id; quote it when you write to support.
Quickstart
Get a key. Keys are created in the EzyInsights app under Settings › API › Keys, by EzyInsights staff or by an administrator of your subscription. A key looks like
ezy_live_k7f3m2x5a4bc_…and is shown once: keep it in your secret store, never in code.Check it.
GET /v1/mesays who the key is, which subscriptions it reads, where each stream's data starts and what your limits are:curl -s https://api.ezyinsights.com/v1/me \ -H "Authorization: Bearer $EZY_API_KEY"Start the change stream from a time in the last 31 days with
since:curl -s "https://api.ezyinsights.com/v1/detections/changes?since=2026-09-23T00:00:00Z&limit=1000" \ -H "Authorization: Bearer $EZY_API_KEY"Store
next_cursorfrom every answer, together with the rows it brought, and pass it ascursornext time instead ofsince. Whilehas_moreis true, call again at once.Poll hourly with the stored cursor. When nothing is new you get an empty page and the same cursor back.
Upsert by
id, keeping the higherversion. Delivery is at least once, so the same version can arrive twice, and an older version must never overwrite a newer one.
A complete loop in Python, standard library only (run it hourly, for example from cron):
import json, os, sqlite3, time, urllib.error, urllib.parse, urllib.request
from datetime import datetime, timedelta, timezone
API = "https://api.ezyinsights.com"
KEY = os.environ["EZY_API_KEY"] # ezy_live_...; never in the code
db = sqlite3.connect("detections.db")
db.execute("CREATE TABLE IF NOT EXISTS detections (id TEXT PRIMARY KEY, version INTEGER, body TEXT)")
db.execute("CREATE TABLE IF NOT EXISTS state (name TEXT PRIMARY KEY, value TEXT)")
def get(path, params):
url = API + path + "?" + urllib.parse.urlencode(params)
request = urllib.request.Request(url, headers={"Authorization": "Bearer " + KEY})
for attempt in range(6):
try:
with urllib.request.urlopen(request, timeout=60) as answer:
return json.load(answer)
except urllib.error.HTTPError as error:
wait = int(error.headers.get("Retry-After") or 2 ** attempt)
if error.code not in (429, 500, 503) or wait > 300:
raise # 429 daily_allowance waits until 00:00 UTC: stop and run again later
time.sleep(wait)
raise RuntimeError("the API kept failing; try again later")
row = db.execute("SELECT value FROM state WHERE name = 'cursor'").fetchone()
if row:
params = {"cursor": row[0]}
else: # the first run: start a day ago (at most 31 days)
since = datetime.now(timezone.utc) - timedelta(days=1)
params = {"since": since.strftime("%Y-%m-%dT%H:%M:%SZ")}
while True:
page = get("/v1/detections/changes", {**params, "limit": 1000})
for d in page["data"]:
db.execute(
"INSERT INTO detections VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET "
"version = excluded.version, body = excluded.body WHERE excluded.version > detections.version",
(d["id"], d["version"], json.dumps(d)),
)
db.execute("INSERT OR REPLACE INTO state VALUES ('cursor', ?)", (page["next_cursor"],))
db.commit() # the rows and the cursor together
if not page["has_more"]:
break
params = {"cursor": page["next_cursor"]}
For history older than 31 days, make a full copy first (next section), then run this loop.
A full copy, gap-free
The change stream starts at most 31 days back. To hold everything since your data starts:
- Read the first page of
GET /v1/detectionsfor the most recent month and keep itsas_of. - Walk
GET /v1/detectionsmonth by month back todata_starts(fromGET /v1/me), followingnext_cursoruntilhas_moreis false in each month, and upsert every row byid, keeping the higherversion. - Start the change stream with
sinceset to that firstas_of, and keep following it as in the quickstart.
Anything that changed during the walk, including a first detection moving into a month you had
already read, or a late file, reaches the stream after that as_of, so the stream carries it. Use
the server's as_of, not your own clock, and start the stream within 31 days of it.
Authentication and keys
Send the key, or an access token, in the Authorization header of every request:
Authorization: Bearer ezy_live_k7f3m2x5a4bc_…
- The format.
ezy_live_, then the key id (12 characters), an underscore and the secret (52 characters), all lower-case lettersa–zand digits2–7. The key id names the key in/v1/meand the app; the secret is shown once, when the key is made, and we keep only a hash of it. - Never in a query string. Keys and tokens are read from the
Authorizationheader only; a key in the URL is ignored and the request is refused with401. - What a key reads. A key is made for some or all of its account's subscriptions and reads only
those. No answer names or counts any other subscription; asking for one is
400with the same words whether it exists or not, and a detection id outside the key's subscriptions is404. - Optional limits on a key. An expiry date, and a list of addresses (IPv4 or IPv6, single or
CIDR) it may be used from; from any other address it is refused with
403. - Rolling a key. In the app, Roll key makes a new key with the same name and scope and keeps the old one working for the overlap you choose (1 hour, 24 hours or 7 days), after which it expires by itself. Deploy the new key within the overlap. Cursors survive a roll.
- Revoking a key. Revoke key stops the key, and every token made from it, within a minute. It cannot be undone.
A revoked, expired or unknown key is 401 unauthorized; a key of a suspended account is
403 forbidden.
Tokens
If your client prefers OAuth 2.0, exchange a key for an access token with the client credentials
grant, and send the token as Authorization: Bearer <token> like a key:
curl -s https://api.ezyinsights.com/oauth/token \
-d grant_type=client_credentials \
-d client_id=k7f3m2x5a4bc \
-d "client_secret=$EZY_API_SECRET"
{ "access_token": "eyJ…", "token_type": "Bearer", "expires_in": 3600,
"scope": "detections:read reports:read exports:create" }
- Two ways to send the credentials:
client_id(the key id) andclient_secret(the secret) in the form body, or as HTTP Basic,Authorization: Basic base64(<key id>:<secret>); not both. As a convenience the whole key (ezy_live_…) is also accepted asclient_secretwhen its id matchesclient_id. - The body is
application/x-www-form-urlencoded, at most 4 KiB, with no parameter given twice;grant_typemust beclient_credentials. - Lifetime: one hour (
expires_in: 3600). When a token expires, requests answer401("the access token has expired"); request a new one. Tokens are not stored and cannot be revoked one by one: revoking the key stops its tokens within a minute. - Scopes. Every key has
detections:read,reports:readandexports:create. Ask for fewer withscope(space-separated, a subset of the key's); leave it out for all of them. On every request the token's scopes are intersected with the key's current ones. - Limits: at most 10 token requests a minute per client id and per address. A token lasts an hour, so one request an hour is enough.
The change stream and its guarantees
GET /v1/detections/changes gives every new and changed detection in the order it reached us,
each with event, created or updated.
- At least once. A detection version may be delivered more than once (after a retried file).
- Upsert by
id;versionnever decreases for an id across the stream. last_scored_atin the stream is as of the version delivered. A re-scoring that changes nothing else does not create a new version, soGET /v1/detections/{id}may show a laterlast_scored_at.- Order is ingest order, not detection time. Recovery files and re-scorings arrive when they arrive.
first_detected_atcan move earlier when an older evaluation of a match is processed after a newer one; that arrives as anupdatedevent with a higherversion.
How to read it:
- Start with
since, a time at most 31 days ago and not in the future: the stream starts at the first change delivered at or after it. Afterwards, always pass thecursorfrom your last answer. One of the two is required, never both. - The cursor is a position in the stream. It never expires, survives a key roll and is valid with any filter. Store it with the rows it brought.
- Pages. A page stops at
limitrows (at most 1,000) or after 50 chunks of the stream (a chunk is one export file's changes for one subscription), whichever comes first. So a page can hold fewer rows thanlimit, even none, withhas_more: true: call again withnext_cursoruntilhas_moreis false, then poll later with the last cursor. - An empty stream. When none of your streams holds anything at or after
since, the answer is an empty page withhas_more: falseand a cursor at the end of the newest stream; poll from it. When you are caught up, the cursor you get back is the one you sent. - Streams per request. Each subscription and content type is one stream, and one request reads
at most 20. A key with more narrows each request with
subscription_idandcontent_type, and keeps a cursor for each. - Filters (next sections but one) are applied to each row. Changing them between calls is allowed, but rows an earlier filter skipped are not delivered again.
Detections in a period
GET /v1/detections?from=…&to=… gives the current state of every detection first detected in the
period: for reports, checks and looking back.
fromandtoare required. Each is a date (2026-09-23) or an ISO 8601 time withZor an offset (2026-09-24T10:00:00Z).fromis inclusive andtoexclusive, except that a date-onlytoincludes its whole day:from=2026-09-23&to=2026-09-23is one day, andto=2026-09-30includes the 30th. Atoin the future is read as now.- Time zones. A date is read in the request's time zone:
time_zoneif you give one (an IANA name such asEurope/Berlin), else the subscription's own time zone when the request resolves to exactly one subscription, else UTC. Days follow the zone's clock changes. Every answer echoes the resolvedfromandto(as UTC times) andtime_zone. - At most 31 days. 31 calendar days when
fromandtoare both dates, so a whole month is always one request; otherwise 31 × 24 hours, plus one hour for a clock change, between the two times. Walk longer periods month by month. - Not before data starts.
frommay not be earlier than the day records begin: the earliestdata_starts(GET /v1/me) of the requested streams, that day's start in the request's time zone. The400namesdata_starts. - Sorts.
sort=-first_detected_at(the default, newest first) orsort=first_detected_at(oldest first); ties are broken byid. - Paging.
limitis up to 1,000 when the request covers one stream (one subscription and content type) and up to 200 when it covers more; at most 4 subscriptions per request. Follownext_cursoruntil it is null.has_morecan be true with an empty next page. - The query cursor refuses a changed request. It carries a hash of the period, the time zone,
the sort, the subscriptions and content types, the filters and
fields, so a walk cannot silently change its question halfway: a cursor sent with anything changed is400("the period or filters changed; start again"). Onlylimitmay change between pages. - Wide requests. A request whose period and subscriptions reach more than 64 shards of our
storage is
400("narrow the period or the subscriptions"). - No
event. Detections here are states, not changes;eventbelongs to the change stream. The same holds forGET /v1/detections/{id}.
What you must know:
- The answer is the current state: a detection re-scored tomorrow shows tomorrow's values.
- A period that ended less than two days ago is still filling, and a walk through it is not a consistent snapshot: matches keep arriving for about a day and a half, and a detection's first detection can move earlier when an older evaluation arrives late, which can move it to a page already read. Newest-first (the default) keeps new arrivals ahead of the cursor. For a complete, updating copy, use the change stream.
- Detections exist from each subscription's
data_starts(GET /v1/me).
Filters and fields
The detection endpoints GET /v1/detections and GET /v1/detections/changes take the same
filters, all combinable. A repeatable filter also takes a comma-separated list
(publisher_id=42,43 is publisher_id=42&publisher_id=43).
| Filter | Rules |
|---|---|
subscription_id |
Repeatable, up to 4. Unset: every subscription of the key. Each must be one of the key's |
content_type |
text or image. Unset: every type the key reads |
publisher_id |
Repeatable, up to 50: only these publishers. A detection without a publisher never matches |
exclude_publisher_id |
Repeatable, up to 50: every publisher but these |
domain |
Repeatable, up to 20: only articles on these domains. A domain, or an http(s) URL without a path; compared in lower case and punycode (münchen.example is xn--mnchen-3ya.example) |
exclude_domain |
Repeatable, up to 20: every domain but these |
min_band |
This band or stronger (below) |
band |
Repeatable: exactly these bands (band=medium&band=high) |
min_score |
0 to 100: strength.score, rounded to a whole number, at least this |
print |
true or false. Image detections are never print |
source_id |
Your own id for the item (source.id), exactly, at most 200 characters |
article_id |
One publisher article (article.id) |
min_band for each content type. Text bands run off < low < medium < high < max
(usage thresholds 25, 50, 75 and 95%); image bands run low < medium < high (score thresholds
30 and 60). The same word is the same step for both types, so min_band=medium keeps text
medium, high and max and image medium and high; min_band=off keeps everything; and
min_band=max keeps only text max and excludes every image. With band, off and max match
no image.
fields returns only the fields you name, so a loader that needs eight columns does not
download forty: fields=first_detected_at,strength,article.url,article.publisher. A path is a
field of the schema or an object holding some (source, strength, article,
article.publisher), which comes back whole. id and version always come back, the detection's
order is kept, and a field the content type does not have stays absent. fields also works on
GET /v1/detections/{id}. Every field is listed under Schemas, and
machine-readably at GET /v1/schema/detection.
Strict parameters. On the detection endpoints, a parameter the endpoint does not know is 400,
and so is an empty value or a single-valued parameter given twice; the detail names it. schema_version=v1 (the default and
only version) is accepted by every detection endpoint and by /v1/schema/detection.
Limits and allowances
- 120 requests a minute per key. Answers to an accepted key or token carry
RateLimit-Limit: 120andRateLimit-Policy: 120;w=60. Over it,429with typerate_limitedandRetry-After: 60. The limit is counted per Cloudflare location, best effort, so a client spread over several locations can exceed it; no remaining count is given. - Unknown credentials are rate-limited. Requests with key ids we have not recently seen are
limited per address (about 10 a minute); over that,
429 rate_limited. - Tokens: 10 requests a minute per client id and per address.
- The daily row allowance. Each account may read a number of detection rows per UTC day: the
rows returned by
/v1/detections,/v1/detections/changesand/v1/detections/{id}count./v1/meshows it aslimits.daily_rows. By default it is the larger of 1,000,000 and three times the rows the account's subscriptions produced in the last 31 days, so a runaway client can re-read about a month three times a day before it stops. Ask us to raise it for a large backfill. - Keys before accounts. When an account goes over, its keys are throttled, those that read the most first, until the rest fit, so a runaway test script does not stop your production loader. The whole account is throttled only when a single key (with the keys it was rolled from) is over on its own; rolling a key does not reset its count.
- Throttled: the detection endpoints answer
429with typedaily_allowance, the reset time (the next 00:00 UTC) in thedetail, andRetry-Afterwith the seconds until then. The cursor you hold stays valid and nothing is lost./v1/me,/v1/schema/detectionand/oauth/tokenkeep working, and/v1/meshowslimits.throttled(keyoraccount) withthrottled_until. - "About". The allowance is counted from our request metering every five minutes, so it takes effect about 8 minutes after it is passed. At the fastest possible rate (10 keys × 120 requests × 1,000 rows a minute) an account can read up to about 10 million rows past it before it is throttled.
Errors
Every error except the token endpoint's is a problem document (RFC 9457), sent as
application/problem+json:
{ "type": "https://api.ezyinsights.com/errors/invalid_request", "title": "Invalid request",
"status": 400, "detail": "limit must be between 1 and 1000",
"request_id": "0b6f1c9e-3a52-4d8e-9f0a-6c1d2e3f4a5b" }
The detail says what was wrong in plain English; request_id is the answer's X-Request-Id.
| Status | Type | When |
|---|---|---|
| 400 | invalid_request |
A parameter is unknown, empty, repeated or out of range; a cursor this endpoint did not give; a query cursor with a changed request; a filter naming a subscription or content type the key does not read; a malformed detection id |
| 401 | unauthorized |
No credential, or a malformed, unknown, expired or revoked one. Carries WWW-Authenticate: Bearer |
| 403 | forbidden |
The credential lacks the endpoint's scope, the address is not allowed for the key, or the account is suspended |
| 404 | not_found |
No endpoint at this path (a trailing slash never matches), or no detection with this id for this key |
| 405 | method_not_allowed |
The path exists but not with this method; Allow lists the methods it answers. Among the API's endpoints, HEAD is answered on /v1/health and /v1/schema/detection only |
| 413 | content_too_large |
A token request body over 4 KiB |
| 429 | rate_limited |
Over a rate limit; wait Retry-After seconds |
| 429 | daily_allowance |
The key or account has used its daily row allowance; Retry-After is the time to 00:00 UTC |
| 500 | internal |
Our fault; retry with backoff |
| 503 | unavailable |
Credentials cannot be verified right now; retry with backoff |
The token endpoint answers errors as RFC 6749 does, { "error", "error_description" }:
400 invalid_request (not a form, a parameter twice, credentials both in the body and as Basic,
malformed Basic credentials), 400 unsupported_grant_type, 400 invalid_scope (a scope the key
does not have) and 401 invalid_client (wrong credentials, or a key that may not be used; with
WWW-Authenticate: Basic realm="ezy-data" when Basic was used). A body over 4 KiB (413), the
rate limit (429) and service faults (500, 503) are problem documents as above.
Retry 429, 500 and 503 after a wait, doubling it each time; never retry 400, 401, 403
or 404 unchanged.
Where data starts and gaps
data_starts.GET /v1/megives, per subscription and content type, the time the stream's records begin: the later of the start (00:00 UTC) of its first day of data and the time the export itself began.nullmeans the stream has no data yet.GET /v1/detectionsrefuses afrombefore that day.data_gaps. Detections arrive in export files, one per 10-minute window. A window whose file never arrived, or could not be read, is invisible in the stream, soGET /v1/melists, per content type, the windows of the last 90 days that aremissing(not arrived a day after it was due; still looked for once a day, and filled if it comes),abandoned(missing for 30 days and no longer looked for) orfailed(arrived but could not be read, until we reset it), newest first, at most 200 each. Use it to tell "no matches" from "no data" when you reconcile.as_of. Every detection answer carriesas_of: the end of the newest finished export window, the earliest over the content types read. Everything up to it has been read; newer windows may still be arriving. It isnullwhile an export has no finished window.GET /v1/healthneeds no key. It answers200withstatus: ok, and503withstatus: degradedwhile one of our alerts that needs someone is open (alerts.page_worthyabove 0), with each export'snewest_done. The one you will meet is ingest stalled: it opens when an export's newest finished window ended more than 45 minutes ago, checked every five minutes, so health turns503about 45 to 50 minutes after the last finished window. During a deliberate pause of ingest for maintenance (alerts.pausedis 1) the windows' age is not judged, so a pause alone keeps health at200;as_ofshows how far the data reaches.
Support
Write to support@ezyinsights.com with the X-Request-Id of the
answer you are asking about. Changes to the API are announced in the changelog.
Reference
Every endpoint, generated from openapi.yaml, the OpenAPI 3.1 description of this API.
GET /v1/detections/changes
The change stream
Every new and changed detection in the order it reached the API, to keep a copy in sync. Start with since (a time in the last 31 days) or with the cursor of your last call: exactly one of the two is required, so no client reads from the beginning of time by leaving a parameter out. Every answer carries next_cursor, even when there is nothing new; store it and pass it as cursor next time. A cursor never expires, survives a key roll and is valid with any filter.
A page stops at limit rows or after 50 chunks of the stream have been read, whichever comes first, so a page can hold fewer than limit rows (even none) with has_more: true: keep following next_cursor until has_more is false, then ask again later with the same cursor. When no stream holds anything at or after since, the answer is an empty page whose cursor points at the end of the newest stream, ready to poll.
Delivery is at least once: upsert by id and keep the higher version. Each detection here carries event (created or updated). A request reads at most 20 streams (one per subscription and content type); a key with more names them with subscription_id and content_type. The rows returned count against the daily row allowance.
Authentication: A key or an access token (Authorization: Bearer …) with the scope detections:read.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
cursor | query | StreamCursor | no | The next_cursor of a previous answer from this endpoint. Exclusive with since. A value this endpoint did not give is 400. |
since | query | string (date-time) | no | Start at the first change delivered at or after this time. An ISO 8601 time with Z or an offset (2026-09-24T10:00:00Z, 2026-09-24T12:00:00+02:00, optional milliseconds), not in the future and at most 31 days (31 × 24 hours) ago; older history is read with GET /v1/detections. Exclusive with cursor. |
limit | query | integer | no | The most rows to return. 1 to 1,000; default 100 |
subscription_id | query | array of integer | no | Only these subscriptions. Repeatable (subscription_id=9001&subscription_id=9002) or comma-separated (subscription_id=9001,9002); at most 4 values. Each must be one of the key's subscriptions (/v1/me): any other is 400 with the same text whether it exists or not ("subscription 1234 is not available to this key"). Unset: every subscription of the key.at most 4 values; each 1 to 999,999,999,999,999 |
content_type | query | string | no | Only this content type. It must be one the key can read; unset means every type in its scope. one of text, image |
publisher_id | query | array of integer | no | Only detections in articles of these publishers (article.publisher.id). Repeatable or comma-separated; at most 50 values. A detection without a publisher never matches.at most 50 values; each 1 to 999,999,999,999,999 |
exclude_publisher_id | query | array of integer | no | Every publisher except these. Repeatable or comma-separated; at most 50 values. A detection without a publisher is kept. at most 50 values; each 1 to 999,999,999,999,999 |
domain | query | array of string | no | Only detections in articles on these domains (article.domain). Repeatable or comma-separated; at most 20 values. Each is a domain (example-daily.test) or an http(s) URL with no path; it is compared in lower case and punycode, so münchen.example and xn--mnchen-3ya.example are the same. A path, port, query, fragment or user name is 400.at most 20 values |
exclude_domain | query | array of string | no | Every domain except these, written as for domain. Repeatable or comma-separated; at most 20 values. A detection without a domain is kept.at most 20 values |
min_band | query | string | no | Only detections at this band or stronger. Text bands run off < low < medium < high < max; image bands low < medium < high, and the same word means the same step for both types. So min_band=off keeps everything, min_band=low drops text off, and min_band=max keeps only text max and excludes every image.one of off, low, medium, high, max |
band | query | array of string | no | Exactly these bands (band=medium&band=high or band=medium,high). Repeatable or comma-separated. off and max match no image.each one of off, low, medium, high, max |
min_score | query | integer | no | Only detections whose strength.score, rounded to a whole number, is at least this: the usage percentage for text (0–100), the match score for images (0–99).0 to 100 |
print | query | boolean | no | true for print articles only, false for everything else. Image detections are never print. |
source_id | query | string | no | Only detections of this agency item (source.id, the agency's own id), exactly as written.at most 200 characters |
article_id | query | integer | no | Only detections in this publisher article (article.id).1 to 999,999,999,999,999 |
fields | query | string | no | Return only these fields: a comma-separated list of paths from the detection schema (fields=first_detected_at,strength,article.url,article.publisher). A path is a field (article.url) or an object holding some (strength, article.publisher), returned whole. id and version are always returned; the fields keep the detection's order; a field the detection's content type does not have stays absent. An unknown path is 400. |
schema_version | query | string | no | The detection schema version. v1, the default, is the only one.one of v1; default v1 |
Responses
| Status | Meaning | Body | Headers |
|---|---|---|---|
| 200 | A page of the stream. | application/json: ChangesPage | X-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy |
| 400 | invalid_request: a parameter is unknown, repeated, empty or out of its range; a cursor this endpoint did not give; a filter naming a subscription or content type outside the key's scope; a malformed id. The credential was verified, so the rate-limit headers are sent. | application/problem+json: Problem | X-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy |
| 401 | unauthorized: no credential, or one that is malformed, unknown, expired or revoked. | application/problem+json: Problem | X-Request-Id, Cache-Control, WWW-Authenticate |
| 403 | forbidden: the account is suspended, the key may not be used from this address, or the credential lacks the endpoint's scope. The rate-limit headers are sent only with the last, once the credential was verified and usable. | application/problem+json: Problem | X-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy |
| 405 | method_not_allowed: the path exists but does not answer this method. Allow lists the methods it does answer. | application/problem+json: Problem | X-Request-Id, Cache-Control, Allow |
| 429 | rate_limited as on every authenticated endpoint (the rate-limit headers only when the credential was verified), or daily_allowance: the key or the account has used its daily row allowance. The detail gives the reset (the next 00:00 UTC) and Retry-After the seconds until then. The cursor you hold stays valid. | application/problem+json: Problem | X-Request-Id, Cache-Control, Retry-After, RateLimit-Limit, RateLimit-Policy |
| 500 | internal: something went wrong on our side. Retry with backoff. The rate-limit headers are sent when the credential had been verified before the fault. | application/problem+json: Problem | X-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy |
| 503 | unavailable: the API cannot verify credentials right now. Retry with backoff. | application/problem+json: Problem | X-Request-Id, Cache-Control |
200 body (ChangesPage)
| Field | Type | Nullable | Description |
|---|---|---|---|
data | array of Detection | no | The detections, in the order they reached the API, each with event. |
next_cursor | StreamCursor | no | A position in the change stream, opaque (base64url). Valid with any filter, never expires and survives a key roll. |
has_more | boolean | no | True to call again now with next_cursor; false when caught up (call again later with it). |
as_of | string (date-time) or null | yes | The data is complete up to this time: the end of the newest finished export window, the earliest over the content types read. Null while an export has none. |
GET /v1/detections
Detections first detected in a period
The current state of every detection whose first_detected_at falls in a period of at most 31 days, filtered, sorted by first detection and paged with a query cursor. from is inclusive and to exclusive; a date-only value is read in the request's time zone, and a date-only to includes its whole day (from=2026-09-23&to=2026-09-23 is one day). The zone is time_zone if given, else the subscription's own when the request resolves to exactly one subscription, else UTC. A to in the future is read as now. The answer echoes the resolved from, to and time_zone.
The period may be at most 31 days: 31 calendar days when from and to are both dates, otherwise 31 × 24 hours and one more hour (for a clock change) between the two times. from may not be earlier than the day records begin (the earliest data_starts in /v1/me of the requested streams, that day's start in the request's time zone). Longer periods are walked month by month.
The period and the filters are fixed for a whole walk: the cursor carries a hash of them, and a cursor used with a changed request is refused with 400 ("the period or filters changed; start again"). limit may change between pages.
A period that ended less than two days ago is still filling, and a walk through it is not a consistent snapshot. Detections here carry no event, which belongs to the change stream. At most 4 subscriptions per request, and a request whose period and subscriptions reach more than 64 shards of storage is 400 ("narrow the period or the subscriptions"). The rows returned count against the daily row allowance.
Authentication: A key or an access token (Authorization: Bearer …) with the scope detections:read.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
from | query | string | yes | The period's start, inclusive: a date (YYYY-MM-DD, that day's start in the request's time zone) or an ISO 8601 time with Z or an offset. |
to | query | string | yes | The period's end, exclusive: a date (the end of that day in the request's time zone, so the day is included) or an ISO 8601 time with Z or an offset. Later than from. A future value is read as now. |
time_zone | query | string | no | An IANA time zone name (Europe/Berlin) for reading date-only from and to. Default: the subscription's own time zone when the request resolves to one subscription, else UTC. Echoed as written. |
sort | query | string | no | Newest first (the default) or oldest first, by first_detected_at, then by id.one of -first_detected_at, first_detected_at; default -first_detected_at |
limit | query | integer | no | The most rows to return: up to 1,000 when the request covers one (subscription, content type) stream, up to 200 when it covers more. 1 to 1,000; default 100 |
cursor | query | QueryCursor | no | The next_cursor of the previous page of the same request. A cursor used with a changed period, time zone, sort, subscriptions or filters is 400. |
subscription_id | query | array of integer | no | Only these subscriptions. Repeatable (subscription_id=9001&subscription_id=9002) or comma-separated (subscription_id=9001,9002); at most 4 values. Each must be one of the key's subscriptions (/v1/me): any other is 400 with the same text whether it exists or not ("subscription 1234 is not available to this key"). Unset: every subscription of the key.at most 4 values; each 1 to 999,999,999,999,999 |
content_type | query | string | no | Only this content type. It must be one the key can read; unset means every type in its scope. one of text, image |
publisher_id | query | array of integer | no | Only detections in articles of these publishers (article.publisher.id). Repeatable or comma-separated; at most 50 values. A detection without a publisher never matches.at most 50 values; each 1 to 999,999,999,999,999 |
exclude_publisher_id | query | array of integer | no | Every publisher except these. Repeatable or comma-separated; at most 50 values. A detection without a publisher is kept. at most 50 values; each 1 to 999,999,999,999,999 |
domain | query | array of string | no | Only detections in articles on these domains (article.domain). Repeatable or comma-separated; at most 20 values. Each is a domain (example-daily.test) or an http(s) URL with no path; it is compared in lower case and punycode, so münchen.example and xn--mnchen-3ya.example are the same. A path, port, query, fragment or user name is 400.at most 20 values |
exclude_domain | query | array of string | no | Every domain except these, written as for domain. Repeatable or comma-separated; at most 20 values. A detection without a domain is kept.at most 20 values |
min_band | query | string | no | Only detections at this band or stronger. Text bands run off < low < medium < high < max; image bands low < medium < high, and the same word means the same step for both types. So min_band=off keeps everything, min_band=low drops text off, and min_band=max keeps only text max and excludes every image.one of off, low, medium, high, max |
band | query | array of string | no | Exactly these bands (band=medium&band=high or band=medium,high). Repeatable or comma-separated. off and max match no image.each one of off, low, medium, high, max |
min_score | query | integer | no | Only detections whose strength.score, rounded to a whole number, is at least this: the usage percentage for text (0–100), the match score for images (0–99).0 to 100 |
print | query | boolean | no | true for print articles only, false for everything else. Image detections are never print. |
source_id | query | string | no | Only detections of this agency item (source.id, the agency's own id), exactly as written.at most 200 characters |
article_id | query | integer | no | Only detections in this publisher article (article.id).1 to 999,999,999,999,999 |
fields | query | string | no | Return only these fields: a comma-separated list of paths from the detection schema (fields=first_detected_at,strength,article.url,article.publisher). A path is a field (article.url) or an object holding some (strength, article.publisher), returned whole. id and version are always returned; the fields keep the detection's order; a field the detection's content type does not have stays absent. An unknown path is 400. |
schema_version | query | string | no | The detection schema version. v1, the default, is the only one.one of v1; default v1 |
Responses
| Status | Meaning | Body | Headers |
|---|---|---|---|
| 200 | A page of the period. | application/json: RangePage | X-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy |
| 400 | invalid_request: a parameter is unknown, repeated, empty or out of its range; a cursor this endpoint did not give; a filter naming a subscription or content type outside the key's scope; a malformed id. The credential was verified, so the rate-limit headers are sent. | application/problem+json: Problem | X-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy |
| 401 | unauthorized: no credential, or one that is malformed, unknown, expired or revoked. | application/problem+json: Problem | X-Request-Id, Cache-Control, WWW-Authenticate |
| 403 | forbidden: the account is suspended, the key may not be used from this address, or the credential lacks the endpoint's scope. The rate-limit headers are sent only with the last, once the credential was verified and usable. | application/problem+json: Problem | X-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy |
| 405 | method_not_allowed: the path exists but does not answer this method. Allow lists the methods it does answer. | application/problem+json: Problem | X-Request-Id, Cache-Control, Allow |
| 429 | rate_limited as on every authenticated endpoint (the rate-limit headers only when the credential was verified), or daily_allowance: the key or the account has used its daily row allowance. The detail gives the reset (the next 00:00 UTC) and Retry-After the seconds until then. The cursor you hold stays valid. | application/problem+json: Problem | X-Request-Id, Cache-Control, Retry-After, RateLimit-Limit, RateLimit-Policy |
| 500 | internal: something went wrong on our side. Retry with backoff. The rate-limit headers are sent when the credential had been verified before the fault. | application/problem+json: Problem | X-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy |
| 503 | unavailable: the API cannot verify credentials right now. Retry with backoff. | application/problem+json: Problem | X-Request-Id, Cache-Control |
200 body (RangePage)
| Field | Type | Nullable | Description |
|---|---|---|---|
data | array of Detection | no | The detections' current states, in the requested sort, without event. |
next_cursor | QueryCursor or any or null | yes | The next page's cursor, or null when the period is read. |
has_more | boolean | no | True when there may be another page (which can be empty). |
from | string (date-time) | no | The period's start as resolved, inclusive. |
to | string (date-time) | no | The period's end as resolved, exclusive (now, if to was in the future). |
time_zone | string | no | The time zone the period was read in. |
as_of | string (date-time) or null | yes | As on the change stream. |
GET /v1/detections/{id}
One detection
The detection, latest version, including the latest last_scored_at. Without event, which belongs to the change stream. An id that is well formed but unknown, or that names a subscription or content type outside the key's scope, is 404, never 403. The row returned counts against the daily row allowance.
Authentication: A key or an access token (Authorization: Bearer …) with the scope detections:read.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string | yes | The detection's id: <t|i>.<subscription>.<source internal id>.<article id>. Anything else is 400.pattern ^[ti]\.(0|[1-9][0-9]{0,14})\.(0|[1-9][0-9]{0,14})\.(0|[1-9][0-9]{0,14})$ |
fields | query | string | no | Return only these fields: a comma-separated list of paths from the detection schema (fields=first_detected_at,strength,article.url,article.publisher). A path is a field (article.url) or an object holding some (strength, article.publisher), returned whole. id and version are always returned; the fields keep the detection's order; a field the detection's content type does not have stays absent. An unknown path is 400. |
schema_version | query | string | no | The detection schema version. v1, the default, is the only one.one of v1; default v1 |
Responses
| Status | Meaning | Body | Headers |
|---|---|---|---|
| 200 | The detection. | application/json: Detection | X-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy |
| 400 | invalid_request: a parameter is unknown, repeated, empty or out of its range; a cursor this endpoint did not give; a filter naming a subscription or content type outside the key's scope; a malformed id. The credential was verified, so the rate-limit headers are sent. | application/problem+json: Problem | X-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy |
| 401 | unauthorized: no credential, or one that is malformed, unknown, expired or revoked. | application/problem+json: Problem | X-Request-Id, Cache-Control, WWW-Authenticate |
| 403 | forbidden: the account is suspended, the key may not be used from this address, or the credential lacks the endpoint's scope. The rate-limit headers are sent only with the last, once the credential was verified and usable. | application/problem+json: Problem | X-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy |
| 404 | not_found: there is no endpoint at this path (a trailing slash never matches), or no detection with this id in the key's scope. The rate-limit headers are sent only with the second, when the credential was verified. | application/problem+json: Problem | X-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy |
| 405 | method_not_allowed: the path exists but does not answer this method. Allow lists the methods it does answer. | application/problem+json: Problem | X-Request-Id, Cache-Control, Allow |
| 429 | rate_limited as on every authenticated endpoint (the rate-limit headers only when the credential was verified), or daily_allowance: the key or the account has used its daily row allowance. The detail gives the reset (the next 00:00 UTC) and Retry-After the seconds until then. The cursor you hold stays valid. | application/problem+json: Problem | X-Request-Id, Cache-Control, Retry-After, RateLimit-Limit, RateLimit-Policy |
| 500 | internal: something went wrong on our side. Retry with backoff. The rate-limit headers are sent when the credential had been verified before the fault. | application/problem+json: Problem | X-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy |
| 503 | unavailable: the API cannot verify credentials right now. Retry with backoff. | application/problem+json: Problem | X-Request-Id, Cache-Control |
GET /v1/me
The account, the key and its subscriptions
Who the credential is, and everything about its own subscriptions a client needs: their time zones, where each stream's records begin (data_starts), the export gaps of the last 90 days (data_gaps) and the limits, with whether the key or the account is throttled by the daily row allowance. Only the key's own subscriptions are listed, never the account's others. Any valid key or token, whatever its scopes; it keeps working while the daily row allowance is used. Cheap to call: its answer is cached for up to a minute.
Authentication: A key or an access token (Authorization: Bearer …), any scope.
Responses
| Status | Meaning | Body | Headers |
|---|---|---|---|
| 200 | The account, the key, its subscriptions and limits. | application/json: Me | X-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy |
| 401 | unauthorized: no credential, or one that is malformed, unknown, expired or revoked. | application/problem+json: Problem | X-Request-Id, Cache-Control, WWW-Authenticate |
| 403 | forbidden: the account is suspended, the key may not be used from this address, or the credential lacks the endpoint's scope. The rate-limit headers are sent only with the last, once the credential was verified and usable. | application/problem+json: Problem | X-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy |
| 405 | method_not_allowed: the path exists but does not answer this method. Allow lists the methods it does answer. | application/problem+json: Problem | X-Request-Id, Cache-Control, Allow |
| 429 | rate_limited: more than 120 requests a minute for this key, or too many requests with key ids not recently seen from this address. Retry after Retry-After seconds. The rate-limit headers are sent only with the first, when the credential was verified; the second comes before any verification. | application/problem+json: Problem | X-Request-Id, Cache-Control, Retry-After, RateLimit-Limit, RateLimit-Policy |
| 500 | internal: something went wrong on our side. Retry with backoff. The rate-limit headers are sent when the credential had been verified before the fault. | application/problem+json: Problem | X-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy |
| 503 | unavailable: the API cannot verify credentials right now. Retry with backoff. | application/problem+json: Problem | X-Request-Id, Cache-Control |
200 body (Me)
| Field | Type | Nullable | Description |
|---|---|---|---|
account | object | no | |
account.id | string | no | The account's id. |
account.name | string | no | The account's name. |
key | object | no | |
key.id | string | no | The key id (the 12 characters after ezy_live_). |
key.name | string | no | The key's name. |
key.scopes | array of string | no | The scopes this credential has now (for a token, those it was issued with that the key still has). |
key.expires_at | string (date-time) or null | yes | When the key expires; null if it does not. |
subscriptions | array of object | no | The key's own subscriptions, and only those. |
subscriptions[].id | integer | no | The subscription id, as detections carry it. |
subscriptions[].name | string or null | yes | The subscription's name. |
subscriptions[].content_types | array of string | no | The content types the key reads for it. each one of text, image |
subscriptions[].time_zone | string | no | The subscription's IANA time zone, used for date-only periods. |
subscriptions[].data_starts | object | no | Per content type, where the stream's records begin; null while it has none. /v1/detections refuses a from before that day. |
subscriptions[].data_starts.text optional | string (date-time) or null | yes | |
subscriptions[].data_starts.image optional | string (date-time) or null | yes | |
subscriptions[].customer_lists | boolean | no | Whether the subscription has a customer list. Always false in this version. |
subscriptions[].classification_since | string (date-time) or null | yes | From when detections carry customer and classification. Null in this version. |
data_gaps | object | no | Per content type in the key's scope, export windows of the last 90 days whose file has not arrived (missing), was given up (abandoned) or could not be read (failed), newest first, at most 200 each. A window listed here has contributed no detections, whatever happened in it. |
data_gaps.text optional | array of Gap | no | |
data_gaps.text[].window_start | string (date-time) | no | The start of the 10-minute export window. |
data_gaps.text[].status | string | no | missing: the window's file has not arrived, a day after it was due; it is still looked for once a day, and if it arrives its detections reach the change stream then. abandoned: missing for 30 days and no longer looked for. failed: the file arrived but could not be read, and stays so until we reset it; its detections reach the change stream if it is then read.one of missing, abandoned, failed |
data_gaps.image optional | array of Gap | no | |
data_gaps.image[].window_start | string (date-time) | no | The start of the 10-minute export window. |
data_gaps.image[].status | string | no | missing: the window's file has not arrived, a day after it was due; it is still looked for once a day, and if it arrives its detections reach the change stream then. abandoned: missing for 30 days and no longer looked for. failed: the file arrived but could not be read, and stays so until we reset it; its detections reach the change stream if it is then read.one of missing, abandoned, failed |
limits | object | no | |
limits.requests_per_minute | integer | no | Requests a minute per key. |
limits.daily_rows | integer | no | The account's daily row allowance for detection reads. |
limits.daily_file_rows | integer | no | The account's daily row allowance for files (exports and deliveries, from a later version). |
limits.exports_per_day | integer | no | Exports an account may create a day (from a later version). |
limits.throttled | string or null | yes | Whether the key or the whole account is throttled by the daily row allowance. one of key, account |
limits.throttled_until optional | string (date-time) | no | When the throttle ends (the next 00:00 UTC); present only while throttled. |
schema_version | string | no | The detection schema version answers use by default. one of v1 |
GET /v1/schema/detection
The v1 detection, machine-readable
Every field of the detection, in the order a detection is written: its dotted path, the flat column name used by files (dots become underscores), its position, type, nullability, the content types that carry it and a description. Any valid key or token. The answer carries an ETag and Cache-Control: no-cache; send the ETag back in If-None-Match and an unchanged schema is 304 with no body.
Authentication: A key or an access token (Authorization: Bearer …), any scope.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
schema_version | query | string | no | The detection schema version. v1, the default, is the only one.one of v1; default v1 |
If-None-Match | header | string | no | An ETag from an earlier answer (or a list of them, or *). |
Responses
| Status | Meaning | Body | Headers |
|---|---|---|---|
| 200 | The schema. | application/json: Schema | X-Request-Id, ETag, Cache-Control, RateLimit-Limit, RateLimit-Policy |
| 304 | Not modified; If-None-Match named the current ETag. No body. | none | X-Request-Id, ETag, Cache-Control, RateLimit-Limit, RateLimit-Policy |
| 400 | invalid_request: a parameter is unknown, repeated, empty or out of its range; a cursor this endpoint did not give; a filter naming a subscription or content type outside the key's scope; a malformed id. The credential was verified, so the rate-limit headers are sent. | application/problem+json: Problem | X-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy |
| 401 | unauthorized: no credential, or one that is malformed, unknown, expired or revoked. | application/problem+json: Problem | X-Request-Id, Cache-Control, WWW-Authenticate |
| 403 | forbidden: the account is suspended, the key may not be used from this address, or the credential lacks the endpoint's scope. The rate-limit headers are sent only with the last, once the credential was verified and usable. | application/problem+json: Problem | X-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy |
| 405 | method_not_allowed: the path exists but does not answer this method. Allow lists the methods it does answer. | application/problem+json: Problem | X-Request-Id, Cache-Control, Allow |
| 429 | rate_limited: more than 120 requests a minute for this key, or too many requests with key ids not recently seen from this address. Retry after Retry-After seconds. The rate-limit headers are sent only with the first, when the credential was verified; the second comes before any verification. | application/problem+json: Problem | X-Request-Id, Cache-Control, Retry-After, RateLimit-Limit, RateLimit-Policy |
| 500 | internal: something went wrong on our side. Retry with backoff. The rate-limit headers are sent when the credential had been verified before the fault. | application/problem+json: Problem | X-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy |
| 503 | unavailable: the API cannot verify credentials right now. Retry with backoff. | application/problem+json: Problem | X-Request-Id, Cache-Control |
200 body (Schema)
| Field | Type | Nullable | Description |
|---|---|---|---|
schema_version | string | no | one of v1 |
fields | array of SchemaField | no | |
fields[].path | string | no | The dotted path in the JSON detection. |
fields[].column | string | no | The flat column name for files; dots become underscores. |
fields[].position | integer | no | 1-based position in the column list. |
fields[].type | string | no | timestamp is an ISO 8601 UTC time with whole seconds and Z.one of string, integer, number, boolean, timestamp |
fields[].nullable | boolean | no | |
fields[].content_types | array of string | no | The content types that carry the field. each one of text, image |
fields[].description | string | no |
HEAD /v1/schema/detection
The schema's headers only
As GET, with the same status and headers and no body.
Authentication: A key or an access token (Authorization: Bearer …), any scope.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
schema_version | query | string | no | The detection schema version. v1, the default, is the only one.one of v1; default v1 |
If-None-Match | header | string | no | An ETag from an earlier answer (or a list of them, or *). |
Responses
| Status | Meaning | Body | Headers |
|---|---|---|---|
| 200 | As GET, without the body. | none | X-Request-Id, ETag, Cache-Control, RateLimit-Limit, RateLimit-Policy |
| 304 | Not modified. | none | X-Request-Id, ETag, Cache-Control, RateLimit-Limit, RateLimit-Policy |
| default | As GET, without the body. | none |
POST /oauth/token
An access token for a key
OAuth 2.0 client credentials (RFC 6749 §4.4). The client is an API key: its id as client_id and its secret as client_secret, either in the form body or as HTTP Basic (Authorization: Basic base64(<key id>:<secret>)), not both. As a convenience, the whole key (ezy_live_…) is also accepted as client_secret when its id matches client_id. The token lasts one hour; request a new one when it expires. Its scopes are the ones asked for in scope (a subset of the key's), or all of the key's; on every request they are intersected with the key's current scopes, and revoking the key stops its tokens within a minute.
Errors are RFC 6749's { "error", "error_description" }, except a body over 4 KiB (413), the rate limit (429) and a service fault (500, 503), which are problem documents. At most 10 requests a minute per client id and per address. The body is at most 4,096 bytes and no parameter may be given twice.
Authentication: None.
Request body
As application/x-www-form-urlencoded:
| Field | Type | Required | Description |
|---|---|---|---|
grant_type | string | yes | one of client_credentials |
client_id | string | no | The key id. Omit when sending HTTP Basic credentials. pattern ^[a-z2-7]{12}$ |
client_secret | string | no | The key's secret (the 52 characters after the key id), or the whole key when its id matches client_id. Omit when sending HTTP Basic credentials. |
scope | string | no | Space-separated scopes, a subset of the key's. Omitted or empty: all of the key's scopes. |
Responses
| Status | Meaning | Body | Headers |
|---|---|---|---|
| 200 | The access token. | application/json: Token | X-Request-Id, Cache-Control, Pragma |
| 400 | invalid_request (not a form, a parameter given twice, credentials both in the body and as Basic, malformed Basic credentials), unsupported_grant_type (grant_type is not client_credentials) or invalid_scope (a scope the key does not have). | application/json: OAuthError | X-Request-Id, Cache-Control, Pragma |
| 401 | invalid_client: the client credentials are not valid, or the key may not be used (revoked, expired, account suspended, address not allowed). With HTTP Basic, the answer carries WWW-Authenticate: Basic realm="ezy-data". | application/json: OAuthError | X-Request-Id, Cache-Control, Pragma, WWW-Authenticate |
| 405 | method_not_allowed: the path exists but does not answer this method. Allow lists the methods it does answer. | application/problem+json: Problem | X-Request-Id, Cache-Control, Allow |
| 413 | content_too_large: the body is larger than 4 KiB. | application/problem+json: Problem | X-Request-Id, Cache-Control |
| 429 | rate_limited: more than 10 token requests a minute for this client or this address, or too many requests with unknown key ids from this address. Retry after the seconds in Retry-After. | application/problem+json: Problem | X-Request-Id, Cache-Control, Retry-After |
| 500 | internal: something went wrong on our side. Retry with backoff. The rate-limit headers are sent when the credential had been verified before the fault. | application/problem+json: Problem | X-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy |
| 503 | unavailable: the API cannot verify credentials right now. Retry with backoff. | application/problem+json: Problem | X-Request-Id, Cache-Control |
200 body (Token)
| Field | Type | Nullable | Description |
|---|---|---|---|
access_token | string | no | Send as Authorization: Bearer <access_token>. |
token_type | string | no | one of Bearer |
expires_in | integer | no | Seconds the token lasts (3,600). |
scope | string | no | The token's scopes, space-separated. |
GET /v1/health
Whether the data is current
No authentication and no rate limit. 200 with status: ok while no alert that needs someone is open; otherwise 503 with status: degraded and the same body. The alert customers meet most is ingest stalled: it opens when an export's newest finished window ended more than 45 minutes ago (checked every five minutes), so health turns 503 about 45 to 50 minutes after the last finished window; an export with no finished window at all, or one older than an hour, is 503 as well. While ingest is deliberately paused (for maintenance, shown as alerts.paused: 1), the windows' age is not judged, so a pause alone keeps 200. newest_done is the end of each export's newest finished window, the same value as_of is built from; alerts gives counts only.
Authentication: None.
Responses
| Status | Meaning | Body | Headers |
|---|---|---|---|
| 200 | Both exports are current. | application/json: Health | X-Request-Id, Cache-Control |
| 405 | method_not_allowed: the path exists but does not answer this method. Allow lists the methods it does answer. | application/problem+json: Problem | X-Request-Id, Cache-Control, Allow |
| 500 | internal: something went wrong on our side. Retry with backoff. The rate-limit headers are sent when the credential had been verified before the fault. | application/problem+json: Problem | X-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy |
| 503 | Degraded: an alert that needs someone is open (alerts.page_worthy above 0; ingest stalled opens 45 minutes after an export's newest finished window ended), or, unless ingest is paused, an export's newest finished window ended more than an hour ago or it has none (newest_done: null). The body is a Health document, not a problem. | application/json: Health | X-Request-Id, Cache-Control |
200 body (Health)
| Field | Type | Nullable | Description |
|---|---|---|---|
status | string | no | one of ok, degraded |
exports | object | no | Per export (newswire for text, imagewire for images). |
exports.newswire | ExportHealth | no | |
exports.newswire.newest_done | string (date-time) or null | yes | The end of the newest finished window; null when there is none. |
exports.imagewire | ExportHealth | no | |
exports.imagewire.newest_done | string (date-time) or null | yes | The end of the newest finished window; null when there is none. |
alerts | object | no | Our operational alerts, as counts only. |
alerts.open | integer | no | Alerts open now. at least 0 |
alerts.page_worthy | integer | no | Open alerts that need someone now (ingest stalled, undeliverable ingest messages, API errors). Above 0, health is 503.at least 0 |
alerts.paused | integer | no | 1 while ingest is deliberately paused, else 0. A pause alone does not make health 503.0 to 1 |
HEAD /v1/health
Health's status only
As GET, with the same status and headers and no body.
Authentication: None.
Responses
| Status | Meaning | Body | Headers |
|---|---|---|---|
| 200 | Both exports are current. | none | X-Request-Id, Cache-Control |
| 503 | Degraded. | none | X-Request-Id, Cache-Control |
Schemas
Detection
One agency item used in one publisher article, for one subscription. Without fields, a detection has every field its content type carries (the Content types column), event only on the change stream; a field the content type does not have is absent, not null, and an unknown value is null. With fields, only the fields asked for are present, and always id and version, so only those two are required.
| Field | Type | Nullable | Content types | Description |
|---|---|---|---|---|
id | string | no | text, image | <t|i>.<subscription>.<source internal id>.<article id>, e.g. t.9001.5000001.7000001. Stable forever; opaque to customers |
version | integer | no | text, image | 1 on first sight; +1 whenever a customer-visible field changes at least 1 |
event | string | no | text, image | created or updated. Present on the change stream only: /v1/detections and /v1/detections/{id} answer current states and leave it outone of created, updated |
content_type | string | no | text, image | text or imageone of text, image |
match_type | string | no | text, image | sentence_fingerprint for text, image_fingerprint for imagesone of sentence_fingerprint, image_fingerprint |
subscription_id | integer | no | text, image | The subscription the detection belongs to |
first_detected_at | string (date-time) | no | text, image | Ours: when the match first reached us. Backfilled rows are flagged (first_detected_estimated) |
first_detected_estimated | boolean | no | text, image | True when first_detected_at is estimated: a backfilled row |
last_scored_at | string (date-time) | no | text, image | The export's matched_at |
exported_at | string (date-time) | no | text, image | When EzyInsights wrote the row |
source | object | no | The agency's item. | |
source.id | string or null | yes | text, image | The agency's own id: a URN, a NewsML id, a UUID |
source.internal_id | integer | no | text, image | EzyInsights' id; links into the dashboard |
source.published_at | string (date-time) or null | yes | text, image | For images, the real publish time, not the record's creation |
source.language | string or null | yes | text | The wire story's language |
source.syndicated | boolean or null | yes | text | Whether the wire story is syndicated |
source.sentences | integer or null | yes | text | The wire story's sentence count, once exported |
source.image_url | string (uri) | no | image | Our signed proxy address of the agency's full-size photo. The signature is an HMAC of the path with no expiry: the link is permanent and works for anyone who has it; revoking it means rotating the signing key, which changes every link |
source.origin_url | string (uri) or null | yes | image | Where the agency's photo came from (imagewire_source_uri) |
strength | object | no | How strong the match is. | |
strength.score | number | no | text, image | Text: usage % (0–100); image: score (0–99) |
strength.band | string | no | text, image | Text off, low, medium, high, max; image low, medium, high. Lower-case in the API; legacy profiles map to the capitalised words. Thresholds per type: text as the export (25/50/75/95), image 30/60one of off, low, medium, high, max |
strength.matched_sentences | integer or null | yes | text | The export's estimate, in wire-story sentences |
strength.article_sentences | integer or null | yes | text | The publisher article's sentence count |
article | object | no | The publisher article the item was found in. | |
article.id | integer | no | text, image | The publisher article (content_story_id) |
article.url | string (uri) or null | yes | text, image | Canonical URL |
article.domain | string or null | yes | text, image | Registrable domain |
article.published_at | string (date-time) or null | yes | text, image | Unknown for 45% of text rows, 12% of image rows |
article.headline | string or null | yes | text, image | The publisher article's headline; null until the exports carry it |
article.publisher | object or null | yes | The publisher, or null when its id is unknown (the detection then carries the domain only). | |
article.publisher.id | integer | yes | text, image | The real publisher id |
article.publisher.name | string or null | yes | text, image | Trimmed |
article.print | boolean | no | text | Whether the publisher is a print title. For print articles, article.url is null outside the agencies licensed for NLA's metadata |
article.image_url | string (uri) or null | yes | image | The publisher's copy (matched_image_url) |
customer | boolean or null | yes | text, image | Whether the publisher is on the subscription's customer list; null until that list is exported |
classification | string or null | yes | text, image | licensed, potentially_unlicensed, unknown; null until classifiedone of licensed, potentially_unlicensed, unknown |
StreamCursor
A position in the change stream, opaque (base64url). Valid with any filter, never expires and survives a key roll.
Type: string.
QueryCursor
A position in one /v1/detections request, opaque (base64url). It carries a hash of the period, time zone, sort, subscriptions and filters, and is refused if they change.
Type: string.
Problem
Every error except the token endpoint's is an RFC 9457 problem document, sent as application/problem+json.
| Field | Type | Nullable | Description |
|---|---|---|---|
type | string (uri) | no | The kind of error. One of:
- https://api.ezyinsights.com/errors/invalid_request (400, Invalid request): a parameter, id or cursor is wrong; the detail says which.
- https://api.ezyinsights.com/errors/unauthorized (401, Unauthorized): no credential, or one that is malformed, unknown, expired or revoked.
- https://api.ezyinsights.com/errors/forbidden (403, Forbidden): a scope is missing, the address is not allowed or the account is suspended.
- https://api.ezyinsights.com/errors/not_found (404, Not found): no endpoint at this path, or no such detection for this key.
- https://api.ezyinsights.com/errors/method_not_allowed (405, Method not allowed): the path does not answer this method; see Allow.
- https://api.ezyinsights.com/errors/content_too_large (413, Content too large): the token request's body is over 4 KiB.
- https://api.ezyinsights.com/errors/rate_limited (429, Too many requests): over a rate limit; see Retry-After.
- https://api.ezyinsights.com/errors/daily_allowance (429, Daily allowance used): the key or account has used its daily row allowance; see Retry-After.
- https://api.ezyinsights.com/errors/internal (500, Internal error): our fault; retry with backoff.
- https://api.ezyinsights.com/errors/unavailable (503, Service unavailable): credentials cannot be verified right now; retry with backoff. |
title | string | no | The type's title, as above. |
status | integer | no | The HTTP status. |
detail | string | no | What was wrong with this request, in plain English. |
request_id | string (uuid) | no | The request's id, the same as X-Request-Id. |
OAuthError
The token endpoint's errors (RFC 6749 §5.2).
| Field | Type | Nullable | Description |
|---|---|---|---|
error | string | no | one of invalid_request, invalid_client, unsupported_grant_type, invalid_scope |
error_description | string | no | What was wrong, in plain English. |