EzyInsights Detections API

Version 1.0.0 · Base URL https://api.ezyinsights.com

openapi.yaml · Changelog

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

  1. 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.

  2. Check it. GET /v1/me says 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"
    
  3. 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"
    
  4. Store next_cursor from every answer, together with the rows it brought, and pass it as cursor next time instead of since. While has_more is true, call again at once.

  5. Poll hourly with the stored cursor. When nothing is new you get an empty page and the same cursor back.

  6. Upsert by id, keeping the higher version. 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:

  1. Read the first page of GET /v1/detections for the most recent month and keep its as_of.
  2. Walk GET /v1/detections month by month back to data_starts (from GET /v1/me), following next_cursor until has_more is false in each month, and upsert every row by id, keeping the higher version.
  3. Start the change stream with since set to that first as_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_…

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" }

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.

How to read it:

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.

What you must know:

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

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

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

NameInTypeRequiredDescription
cursorqueryStreamCursornoThe next_cursor of a previous answer from this endpoint. Exclusive with since. A value this endpoint did not give is 400.
sincequerystring (date-time)noStart 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.
limitqueryintegernoThe most rows to return.
1 to 1,000; default 100
subscription_idqueryarray of integernoOnly 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_typequerystringnoOnly this content type. It must be one the key can read; unset means every type in its scope.
one of text, image
publisher_idqueryarray of integernoOnly 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_idqueryarray of integernoEvery 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
domainqueryarray of stringnoOnly 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_domainqueryarray of stringnoEvery 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_bandquerystringnoOnly 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
bandqueryarray of stringnoExactly 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_scorequeryintegernoOnly 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
printquerybooleannotrue for print articles only, false for everything else. Image detections are never print.
source_idquerystringnoOnly detections of this agency item (source.id, the agency's own id), exactly as written.
at most 200 characters
article_idqueryintegernoOnly detections in this publisher article (article.id).
1 to 999,999,999,999,999
fieldsquerystringnoReturn 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_versionquerystringnoThe detection schema version. v1, the default, is the only one.
one of v1; default v1

Responses

StatusMeaningBodyHeaders
200A page of the stream.application/json: ChangesPageX-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy
400invalid_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: ProblemX-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy
401unauthorized: no credential, or one that is malformed, unknown, expired or revoked.application/problem+json: ProblemX-Request-Id, Cache-Control, WWW-Authenticate
403forbidden: 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: ProblemX-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy
405method_not_allowed: the path exists but does not answer this method. Allow lists the methods it does answer.application/problem+json: ProblemX-Request-Id, Cache-Control, Allow
429rate_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: ProblemX-Request-Id, Cache-Control, Retry-After, RateLimit-Limit, RateLimit-Policy
500internal: 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: ProblemX-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy
503unavailable: the API cannot verify credentials right now. Retry with backoff.application/problem+json: ProblemX-Request-Id, Cache-Control

200 body (ChangesPage)

FieldTypeNullableDescription
dataarray of DetectionnoThe detections, in the order they reached the API, each with event.
next_cursorStreamCursornoA position in the change stream, opaque (base64url). Valid with any filter, never expires and survives a key roll.
has_morebooleannoTrue to call again now with next_cursor; false when caught up (call again later with it).
as_ofstring (date-time) or nullyesThe 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

NameInTypeRequiredDescription
fromquerystringyesThe 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.
toquerystringyesThe 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_zonequerystringnoAn 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.
sortquerystringnoNewest 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
limitqueryintegernoThe 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
cursorqueryQueryCursornoThe 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_idqueryarray of integernoOnly 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_typequerystringnoOnly this content type. It must be one the key can read; unset means every type in its scope.
one of text, image
publisher_idqueryarray of integernoOnly 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_idqueryarray of integernoEvery 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
domainqueryarray of stringnoOnly 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_domainqueryarray of stringnoEvery 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_bandquerystringnoOnly 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
bandqueryarray of stringnoExactly 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_scorequeryintegernoOnly 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
printquerybooleannotrue for print articles only, false for everything else. Image detections are never print.
source_idquerystringnoOnly detections of this agency item (source.id, the agency's own id), exactly as written.
at most 200 characters
article_idqueryintegernoOnly detections in this publisher article (article.id).
1 to 999,999,999,999,999
fieldsquerystringnoReturn 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_versionquerystringnoThe detection schema version. v1, the default, is the only one.
one of v1; default v1

Responses

StatusMeaningBodyHeaders
200A page of the period.application/json: RangePageX-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy
400invalid_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: ProblemX-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy
401unauthorized: no credential, or one that is malformed, unknown, expired or revoked.application/problem+json: ProblemX-Request-Id, Cache-Control, WWW-Authenticate
403forbidden: 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: ProblemX-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy
405method_not_allowed: the path exists but does not answer this method. Allow lists the methods it does answer.application/problem+json: ProblemX-Request-Id, Cache-Control, Allow
429rate_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: ProblemX-Request-Id, Cache-Control, Retry-After, RateLimit-Limit, RateLimit-Policy
500internal: 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: ProblemX-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy
503unavailable: the API cannot verify credentials right now. Retry with backoff.application/problem+json: ProblemX-Request-Id, Cache-Control

200 body (RangePage)

FieldTypeNullableDescription
dataarray of DetectionnoThe detections' current states, in the requested sort, without event.
next_cursorQueryCursor or any or nullyesThe next page's cursor, or null when the period is read.
has_morebooleannoTrue when there may be another page (which can be empty).
fromstring (date-time)noThe period's start as resolved, inclusive.
tostring (date-time)noThe period's end as resolved, exclusive (now, if to was in the future).
time_zonestringnoThe time zone the period was read in.
as_ofstring (date-time) or nullyesAs 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

NameInTypeRequiredDescription
idpathstringyesThe 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})$
fieldsquerystringnoReturn 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_versionquerystringnoThe detection schema version. v1, the default, is the only one.
one of v1; default v1

Responses

StatusMeaningBodyHeaders
200The detection.application/json: DetectionX-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy
400invalid_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: ProblemX-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy
401unauthorized: no credential, or one that is malformed, unknown, expired or revoked.application/problem+json: ProblemX-Request-Id, Cache-Control, WWW-Authenticate
403forbidden: 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: ProblemX-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy
404not_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: ProblemX-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy
405method_not_allowed: the path exists but does not answer this method. Allow lists the methods it does answer.application/problem+json: ProblemX-Request-Id, Cache-Control, Allow
429rate_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: ProblemX-Request-Id, Cache-Control, Retry-After, RateLimit-Limit, RateLimit-Policy
500internal: 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: ProblemX-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy
503unavailable: the API cannot verify credentials right now. Retry with backoff.application/problem+json: ProblemX-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

StatusMeaningBodyHeaders
200The account, the key, its subscriptions and limits.application/json: MeX-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy
401unauthorized: no credential, or one that is malformed, unknown, expired or revoked.application/problem+json: ProblemX-Request-Id, Cache-Control, WWW-Authenticate
403forbidden: 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: ProblemX-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy
405method_not_allowed: the path exists but does not answer this method. Allow lists the methods it does answer.application/problem+json: ProblemX-Request-Id, Cache-Control, Allow
429rate_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: ProblemX-Request-Id, Cache-Control, Retry-After, RateLimit-Limit, RateLimit-Policy
500internal: 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: ProblemX-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy
503unavailable: the API cannot verify credentials right now. Retry with backoff.application/problem+json: ProblemX-Request-Id, Cache-Control

200 body (Me)

FieldTypeNullableDescription
accountobjectno
account.idstringnoThe account's id.
account.namestringnoThe account's name.
keyobjectno
key.idstringnoThe key id (the 12 characters after ezy_live_).
key.namestringnoThe key's name.
key.scopesarray of stringnoThe scopes this credential has now (for a token, those it was issued with that the key still has).
key.expires_atstring (date-time) or nullyesWhen the key expires; null if it does not.
subscriptionsarray of objectnoThe key's own subscriptions, and only those.
subscriptions[].idintegernoThe subscription id, as detections carry it.
subscriptions[].namestring or nullyesThe subscription's name.
subscriptions[].content_typesarray of stringnoThe content types the key reads for it.
each one of text, image
subscriptions[].time_zonestringnoThe subscription's IANA time zone, used for date-only periods.
subscriptions[].data_startsobjectnoPer 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 optionalstring (date-time) or nullyes
subscriptions[].data_starts.image optionalstring (date-time) or nullyes
subscriptions[].customer_listsbooleannoWhether the subscription has a customer list. Always false in this version.
subscriptions[].classification_sincestring (date-time) or nullyesFrom when detections carry customer and classification. Null in this version.
data_gapsobjectnoPer 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 optionalarray of Gapno
data_gaps.text[].window_startstring (date-time)noThe start of the 10-minute export window.
data_gaps.text[].statusstringnomissing: 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 optionalarray of Gapno
data_gaps.image[].window_startstring (date-time)noThe start of the 10-minute export window.
data_gaps.image[].statusstringnomissing: 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
limitsobjectno
limits.requests_per_minuteintegernoRequests a minute per key.
limits.daily_rowsintegernoThe account's daily row allowance for detection reads.
limits.daily_file_rowsintegernoThe account's daily row allowance for files (exports and deliveries, from a later version).
limits.exports_per_dayintegernoExports an account may create a day (from a later version).
limits.throttledstring or nullyesWhether the key or the whole account is throttled by the daily row allowance.
one of key, account
limits.throttled_until optionalstring (date-time)noWhen the throttle ends (the next 00:00 UTC); present only while throttled.
schema_versionstringnoThe 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

NameInTypeRequiredDescription
schema_versionquerystringnoThe detection schema version. v1, the default, is the only one.
one of v1; default v1
If-None-MatchheaderstringnoAn ETag from an earlier answer (or a list of them, or *).

Responses

StatusMeaningBodyHeaders
200The schema.application/json: SchemaX-Request-Id, ETag, Cache-Control, RateLimit-Limit, RateLimit-Policy
304Not modified; If-None-Match named the current ETag. No body.noneX-Request-Id, ETag, Cache-Control, RateLimit-Limit, RateLimit-Policy
400invalid_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: ProblemX-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy
401unauthorized: no credential, or one that is malformed, unknown, expired or revoked.application/problem+json: ProblemX-Request-Id, Cache-Control, WWW-Authenticate
403forbidden: 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: ProblemX-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy
405method_not_allowed: the path exists but does not answer this method. Allow lists the methods it does answer.application/problem+json: ProblemX-Request-Id, Cache-Control, Allow
429rate_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: ProblemX-Request-Id, Cache-Control, Retry-After, RateLimit-Limit, RateLimit-Policy
500internal: 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: ProblemX-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy
503unavailable: the API cannot verify credentials right now. Retry with backoff.application/problem+json: ProblemX-Request-Id, Cache-Control

200 body (Schema)

FieldTypeNullableDescription
schema_versionstringno
one of v1
fieldsarray of SchemaFieldno
fields[].pathstringnoThe dotted path in the JSON detection.
fields[].columnstringnoThe flat column name for files; dots become underscores.
fields[].positionintegerno1-based position in the column list.
fields[].typestringnotimestamp is an ISO 8601 UTC time with whole seconds and Z.
one of string, integer, number, boolean, timestamp
fields[].nullablebooleanno
fields[].content_typesarray of stringnoThe content types that carry the field.
each one of text, image
fields[].descriptionstringno

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

NameInTypeRequiredDescription
schema_versionquerystringnoThe detection schema version. v1, the default, is the only one.
one of v1; default v1
If-None-MatchheaderstringnoAn ETag from an earlier answer (or a list of them, or *).

Responses

StatusMeaningBodyHeaders
200As GET, without the body.noneX-Request-Id, ETag, Cache-Control, RateLimit-Limit, RateLimit-Policy
304Not modified.noneX-Request-Id, ETag, Cache-Control, RateLimit-Limit, RateLimit-Policy
defaultAs 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:

FieldTypeRequiredDescription
grant_typestringyes
one of client_credentials
client_idstringnoThe key id. Omit when sending HTTP Basic credentials.
pattern ^[a-z2-7]{12}$
client_secretstringnoThe 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.
scopestringnoSpace-separated scopes, a subset of the key's. Omitted or empty: all of the key's scopes.

Responses

StatusMeaningBodyHeaders
200The access token.application/json: TokenX-Request-Id, Cache-Control, Pragma
400invalid_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: OAuthErrorX-Request-Id, Cache-Control, Pragma
401invalid_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: OAuthErrorX-Request-Id, Cache-Control, Pragma, WWW-Authenticate
405method_not_allowed: the path exists but does not answer this method. Allow lists the methods it does answer.application/problem+json: ProblemX-Request-Id, Cache-Control, Allow
413content_too_large: the body is larger than 4 KiB.application/problem+json: ProblemX-Request-Id, Cache-Control
429rate_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: ProblemX-Request-Id, Cache-Control, Retry-After
500internal: 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: ProblemX-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy
503unavailable: the API cannot verify credentials right now. Retry with backoff.application/problem+json: ProblemX-Request-Id, Cache-Control

200 body (Token)

FieldTypeNullableDescription
access_tokenstringnoSend as Authorization: Bearer <access_token>.
token_typestringno
one of Bearer
expires_inintegernoSeconds the token lasts (3,600).
scopestringnoThe 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

StatusMeaningBodyHeaders
200Both exports are current.application/json: HealthX-Request-Id, Cache-Control
405method_not_allowed: the path exists but does not answer this method. Allow lists the methods it does answer.application/problem+json: ProblemX-Request-Id, Cache-Control, Allow
500internal: 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: ProblemX-Request-Id, Cache-Control, RateLimit-Limit, RateLimit-Policy
503Degraded: 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: HealthX-Request-Id, Cache-Control

200 body (Health)

FieldTypeNullableDescription
statusstringno
one of ok, degraded
exportsobjectnoPer export (newswire for text, imagewire for images).
exports.newswireExportHealthno
exports.newswire.newest_donestring (date-time) or nullyesThe end of the newest finished window; null when there is none.
exports.imagewireExportHealthno
exports.imagewire.newest_donestring (date-time) or nullyesThe end of the newest finished window; null when there is none.
alertsobjectnoOur operational alerts, as counts only.
alerts.openintegernoAlerts open now.
at least 0
alerts.page_worthyintegernoOpen alerts that need someone now (ingest stalled, undeliverable ingest messages, API errors). Above 0, health is 503.
at least 0
alerts.pausedintegerno1 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

StatusMeaningBodyHeaders
200Both exports are current.noneX-Request-Id, Cache-Control
503Degraded.noneX-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.

FieldTypeNullableContent typesDescription
idstringnotext, image<t|i>.<subscription>.<source internal id>.<article id>, e.g. t.9001.5000001.7000001. Stable forever; opaque to customers
versionintegernotext, image1 on first sight; +1 whenever a customer-visible field changes
at least 1
eventstringnotext, imagecreated or updated. Present on the change stream only: /v1/detections and /v1/detections/{id} answer current states and leave it out
one of created, updated
content_typestringnotext, imagetext or image
one of text, image
match_typestringnotext, imagesentence_fingerprint for text, image_fingerprint for images
one of sentence_fingerprint, image_fingerprint
subscription_idintegernotext, imageThe subscription the detection belongs to
first_detected_atstring (date-time)notext, imageOurs: when the match first reached us. Backfilled rows are flagged (first_detected_estimated)
first_detected_estimatedbooleannotext, imageTrue when first_detected_at is estimated: a backfilled row
last_scored_atstring (date-time)notext, imageThe export's matched_at
exported_atstring (date-time)notext, imageWhen EzyInsights wrote the row
sourceobjectnoThe agency's item.
source.idstring or nullyestext, imageThe agency's own id: a URN, a NewsML id, a UUID
source.internal_idintegernotext, imageEzyInsights' id; links into the dashboard
source.published_atstring (date-time) or nullyestext, imageFor images, the real publish time, not the record's creation
source.languagestring or nullyestextThe wire story's language
source.syndicatedboolean or nullyestextWhether the wire story is syndicated
source.sentencesinteger or nullyestextThe wire story's sentence count, once exported
source.image_urlstring (uri)noimageOur 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_urlstring (uri) or nullyesimageWhere the agency's photo came from (imagewire_source_uri)
strengthobjectnoHow strong the match is.
strength.scorenumbernotext, imageText: usage % (0–100); image: score (0–99)
strength.bandstringnotext, imageText 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/60
one of off, low, medium, high, max
strength.matched_sentencesinteger or nullyestextThe export's estimate, in wire-story sentences
strength.article_sentencesinteger or nullyestextThe publisher article's sentence count
articleobjectnoThe publisher article the item was found in.
article.idintegernotext, imageThe publisher article (content_story_id)
article.urlstring (uri) or nullyestext, imageCanonical URL
article.domainstring or nullyestext, imageRegistrable domain
article.published_atstring (date-time) or nullyestext, imageUnknown for 45% of text rows, 12% of image rows
article.headlinestring or nullyestext, imageThe publisher article's headline; null until the exports carry it
article.publisherobject or nullyesThe publisher, or null when its id is unknown (the detection then carries the domain only).
article.publisher.idintegeryestext, imageThe real publisher id
article.publisher.namestring or nullyestext, imageTrimmed
article.printbooleannotextWhether the publisher is a print title. For print articles, article.url is null outside the agencies licensed for NLA's metadata
article.image_urlstring (uri) or nullyesimageThe publisher's copy (matched_image_url)
customerboolean or nullyestext, imageWhether the publisher is on the subscription's customer list; null until that list is exported
classificationstring or nullyestext, imagelicensed, potentially_unlicensed, unknown; null until classified
one 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.

FieldTypeNullableDescription
typestring (uri)noThe 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.
titlestringnoThe type's title, as above.
statusintegernoThe HTTP status.
detailstringnoWhat was wrong with this request, in plain English.
request_idstring (uuid)noThe request's id, the same as X-Request-Id.

OAuthError

The token endpoint's errors (RFC 6749 §5.2).

FieldTypeNullableDescription
errorstringno
one of invalid_request, invalid_client, unsupported_grant_type, invalid_scope
error_descriptionstringnoWhat was wrong, in plain English.