New to the API? See the plans and how records are counted, or get your API key in about a minute.
etailinsights API v1
Read-only access to the etailinsights database of eCommerce companies, websites and contacts.
Base URL: https://mcp.etailinsights.com. Every path below is relative to it. Reference revision 2026-11 (records metering).
Authentication
Send your API key in the X-ETI-API-Key header on every request except GET /v1/health.
Keys look like eti_ followed by 32 lowercase hex characters. A missing, malformed, unknown or revoked key
returns 401 unauthorized; the body never says which. The MCP endpoint, POST /mcp, takes the same key as
Authorization: Bearer instead — and only there: a REST route ignores an Authorization header
(see MCP and connectors).
curl -H "X-ETI-API-Key: eti_0123456789abcdef0123456789abcdef" \
"https://mcp.etailinsights.com/v1/companies/by-domain?domain=glossier.com"
Response envelope
Every response — success or error — has the same shape. Exactly one of data and error is non-null.
{
"data": { ... }, // the record(s), or null on error
"error": null, // or {"code": "...", "message": "..."}
"meta": {
"request_id": "6f1c0e2a-…", // server-generated; quote it when reporting an issue
"latency_ms": 41, // server-side processing time
"records_returned": 1 // records this response counted against your allotment (0 on errors)
}
}
error.code is stable and machine-readable; error.message is for people and may change. Timestamps are UTC ISO-8601 with a Z suffix.
Endpoints
GET /v1/health
No authentication. Returns 200 with {"status": "ok", "checks": {"mysql": "ok", "dynamodb": "ok"}, "version": "…"},
or 503 with "status": "degraded" when a dependency check fails. Not wrapped in the envelope.
GET /robots.txt
No authentication. Plain text that asks crawlers to stay off this host, apart from the paths MCP clients call before signing in. Not wrapped in the envelope, not counted.
GET /v1/companies/by-domain
Look up one company by its web domain. Returns a CompanyRecord. One record.
| Query parameter | Type | Notes |
|---|---|---|
domain | string, required | 3–253 characters. Protocol, www. and a trailing slash are stripped; matching is case-insensitive. https://www.glossier.com/ and glossier.com are the same lookup. |
Errors: 400 invalid_request (missing or malformed domain), 404 not_found.
GET /v1/companies/{company_id}
Look up one company by its public ID. Returns a CompanyRecord. One record.
| Path parameter | Type | Notes |
|---|---|---|
company_id | string, required | Public company identifier, eti_co_{uuid}, as returned by every other endpoint. Any other prefix is 400 invalid_request. |
Errors: 400 invalid_request, 404 not_found.
GET /v1/companies/{company_id}/contacts
The company's published contacts, C-suite first, paged. Each contact returned counts as one record (see Records and limits).
| Parameter | Type | Notes |
|---|---|---|
company_id | path, required | eti_co_{uuid}. |
limit | query, integer | Page size requested, 1–50; default 10. The page you receive may be shorter when it would cross your monthly allotment — limit in the response is the size actually served. |
offset | query, integer | Zero-based; default 0. Use next_offset from the previous page. |
seniority | query, enum | One of c_suite, director, manager, other. Any other value is 400. |
{
"data": {
"company_id": "eti_co_abc12345-1111-2222-3333-444455556666",
"contacts": [ { ...ContactRecord... }, ... ],
"total": 14, // contacts matching the filter
"offset": 0,
"limit": 10, // the page size served
"returned": 10, // len(contacts) — the records this response counted
"has_more": true,
"next_offset": 10 // present only when has_more
},
"error": null,
"meta": { "request_id": "…", "latency_ms": 63, "records_returned": 10 }
}
Errors: 400 invalid_request, 403 regulatory_restriction (the company is in a GDPR/CASL-restricted country; contacts are withheld, the company itself is still readable), 404 not_found.
A company with no matching contacts is 200 with an empty list, not 404.
GET /v1/contacts/by-email
Look up one contact by email address. Returns the ContactRecord plus a CompanySummary of their company. One record (the summary is part of it).
| Query parameter | Type | Notes |
|---|---|---|
email | string, required | Syntactically validated, then lowercased before the lookup. A malformed address is 400 invalid_request. |
{ "data": { "contact": { ...ContactRecord... }, "company": { ...CompanySummary... } }, "error": null, "meta": { ... } }
Errors: 400 invalid_request, 403 regulatory_restriction, 404 not_found.
GET /v1/website-info/by-domain
Website metadata and the technology platforms detected on it. Returns a WebsiteInfoRecord. One record (the platforms list is part of it).
| Query parameter | Type | Notes |
|---|---|---|
domain | string, required | 3–253 characters. Protocol, www. and a trailing slash are stripped; other subdomains are kept (shop.example.com queries as-is). |
Errors: 400 invalid_request, 404 not_found.
Application surface — POST /v1/search/websites, POST /v1/search/companies, POST /v1/search/executives
Criteria search is reserved for the etailinsights application. These paths are not part of the customer API: a key without the
search:advanced scope receives 404 not_found, exactly as for a path that does not exist. Search results are
not records and never count against an allotment.
Data models
All fields are present in every response; a value that is unknown is null (or an empty list for list fields). No field is hidden by plan — one call returns the full record.
CompanyRecord
| Field | Type | Notes |
|---|---|---|
id | string | Public identifier, eti_co_{uuid}. Stable; use it for /v1/companies/{company_id}. |
name | string | Canonical company name. |
domain | string | null | Primary web domain, bare (no protocol or www.). |
business_type | string | null | eCommerce model, e.g. eCommerce Pure Play, Brick and Mortar. |
description | string | null | |
profile_type | enum | Certified (researched by etailinsights) or Community (auto-generated). |
revenue_year | integer | The year revenue_current refers to; identical across all records in a response. |
revenue_current | integer | null | Estimated online revenue for revenue_year, USD. |
revenue_prior | integer | null | Estimated online revenue for revenue_year − 1, USD. |
revenue_history | object | Year (string) → estimated online revenue, USD, for 2020–2024. Years without data are omitted. |
employee_range | string | null | Banded, e.g. 51-200. |
employee_count | integer | null | Precise headcount where known. |
country | string | null | ISO 3166-1 alpha-2. |
state | string | null | US state abbreviation or non-US region. |
city | string | null | |
founded_year | integer | null | |
ownership | string | null | Public or Private. |
ticker | string | null | |
is_b2b, is_b2c | boolean | null | |
store_count | integer | null | Physical retail locations. |
phone | string | null | Corporate phone. |
linkedin_url | string | null | |
contact_count | integer | null | Published contacts available through /contacts. |
all_websites | string[] | Every published domain; the primary domain first, the rest alphabetical. |
industry_vertical | string | null | e.g. Apparel, Accessories, and Shoes. Distinct from business_type. |
shipping_destinations | string[] | ISO alpha-2 codes the company's websites ship to, de-duplicated, sorted. |
enrichment | object | Additive, best-effort enrichment fields (social and traffic data, sales series). Empty when none is available; a lookup never fails because of it. |
CompanySummary
The four identity fields of a CompanyRecord — id, name, domain, business_type — embedded in /v1/contacts/by-email responses. Fetch the full record with /v1/companies/{company_id}.
ContactRecord
| Field | Type | Notes |
|---|---|---|
id | string | Public identifier, eti_ct_{uuid}. |
company_id | string | eti_co_{uuid} of the contact's company. |
first_name, last_name | string | Always present. |
title | string | null | Free-text job title. |
role | string | null | Normalized role, e.g. CEO/President/Owner. |
seniority | enum | null | c_suite, director, manager, other. |
is_head_of_ecommerce, is_head_of_fulfillment | boolean | null | |
email | string | null | Null when no verified email exists. |
phone | string | null | Best available: direct line, then alternate, then the company's corporate line. |
linkedin_url | string | null | |
country | string | null | ISO alpha-2; may differ from the company's. |
WebsiteInfoRecord
| Field | Type | Notes |
|---|---|---|
domain | string | Normalized (lowercased, www. stripped). |
company_id | string | eti_co_{uuid}. |
company_name | string | |
is_primary | boolean | Whether this is the company's primary website. |
brand_name | string | null | When different from the company name. |
description | string | null | |
launch_year | integer | null | |
product_count | integer | null | |
orders_per_day | integer | null | Estimated. |
ships_internationally, has_warehouse | boolean | null | null = unknown, false = known no. |
twitter, facebook, instagram, tiktok, blog | string | null | Profile / blog URLs. |
platforms | PlatformRecord[] | Technology detected on the site, ordered by category then name. Each item: name (string) and category (string — commerce, analytics, esp, payment, shipping, … extensible; handle unknown values). |
Records and limits
Your plan buys records per month. A record is one company, one website, or one contact returned in a successful (2xx) response. Errors return no records and count nothing. Calls are separately rate-limited per minute (and, on some keys, per day) so one runaway loop cannot flood the service.
Records per endpoint
| Endpoint | Records per 2xx response |
|---|---|
GET /v1/companies/by-domain | 1 |
GET /v1/companies/{company_id} | 1 |
GET /v1/companies/{company_id}/contacts | One per contact returned — the page's returned count (up to limit) |
GET /v1/contacts/by-email | 1 |
GET /v1/website-info/by-domain | 1 |
meta.records_returned on every response is exactly this number.
Plans
| Tier | Plan limits |
|---|---|
| Explore | 5,000 records/month · up to 17 calls/minute |
| Scale | 10,000 records/month · up to 167 calls/minute |
| Summit | 25,000 records/month · up to 833 calls/minute |
| Enterprise | custom records/month, set per key · up to 1,000 calls/minute |
The month is the UTC calendar month; your count resets at 00:00 UTC on the 1st. Need more this month, or a bigger plan? Contact support@etailinsights.com.
Limit headers
Every authenticated response carries the state of each limit including that response, so you can pace a client without a separate call.
| Header | Meaning |
|---|---|
X-Records-Limit | Records your key may return this month. |
X-Records-Used | Records counted so far this month, including this response. |
X-Records-Remaining | max(0, limit − used). |
X-Records-Reset | Unix epoch seconds of the next month's start (00:00 UTC on the 1st). |
X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset | Calls per minute for your tier, calls left in the current minute, epoch seconds when the minute window resets. |
X-Daily-Limit, X-Daily-Remaining, X-Daily-Reset | Every key has a daily call cap equal to its monthly records limit (X-Daily-Limit equals X-Records-Limit unless support has set a fixed cap by hand): calls per day, calls left today, epoch seconds of the next midnight UTC. Present on every counted response; absent when there is nothing to count (a records limit of 0, where every lookup is refused with record_limit_exceeded) and on a response the counter could not record. The cap follows an upgrade, a new allotment or a grant on the next request, and today's count carries. |
Retry-After | On a 429 only: seconds until the limit that fired resets. |
X-Records-Limit and X-Records-Reset only. The request still counts; the next normal response shows the true X-Records-Used.Reaching the limit
A request that would take you past your monthly allotment is refused with 429 and error.code = "record_limit_exceeded". There is no overage: the key simply stops until the month resets, a grant is added, or the plan changes.
HTTP/1.1 429 Too Many Requests
Retry-After: 864000
X-Records-Limit: 10000
X-Records-Used: 10000
X-Records-Remaining: 0
X-Records-Reset: 1796083200
{
"data": null,
"error": {
"code": "record_limit_exceeded",
"message": "You've hit your limit for the month; it resets 2026-12-01T00:00:00Z. Contact support@etailinsights.com to increase your limit."
},
"meta": { "request_id": "…", "latency_ms": 3, "records_returned": 0 }
}
Retry-Afteron this error is the time to the month's start and can be millions of seconds. That is correct; do not treat it as a parsing error.- Contacts near the limit. A contacts page that would cross the allotment is served short: you get the contacts that still fit,
limitis the size served,has_moreandnext_offsetwork as usual, andX-Records-Remainingis0. The next request is the429. - A key with an allotment of 0 is blocked outright: every lookup is
429withX-Records-Limit: 0. When a request is refused before it can be counted,X-Records-Usedreports the allotment (or0for a blocked key) because no count was read. - The three
429codes are distinct:rate_limited(per minute; waitRetry-Afterseconds),daily_limit_exceeded(per day; resets at midnight UTC) andrecord_limit_exceeded(per month). Each carries its own header family. Because the daily cap equals the monthly records limit, a key that spends a whole month's records in one day is toldrecord_limit_exceeded— the month is the reason, not the day — while a key that made its daily number of calls with records to spare getsdaily_limit_exceededand can continue at midnight UTC. - A refused request is still a call for the per-minute limit, but returns no records and never counts against your allotment.
MCP and connectors
The same records are available to AI assistants through the Model Context Protocol (MCP), at one endpoint.
POST /mcp
Streamable HTTP, answered with one JSON response per POST: no event streams and no sessions. Protocol revisions
2025-06-18, 2025-11-25 and 2026-07-28 are served. GET and DELETE on
/mcp return 405.
- Authentication:
Authorization: Bearer <your API key>. Clients that can set arbitrary headers may sendX-ETI-API-Keyinstead — never both. Assistants that sign in instead send an OAuth access token asAuthorization: Bearer(Connecting by sign-in). A missing, malformed, unknown or revoked key or token returns401with the error envelope above and aWWW-Authenticate: Bearerchallenge, whatever the method. When a credential was sent and refused, the challenge also carrieserror="invalid_token"(RFC 6750); a malformed one,error="invalid_request". - Origin: a request carrying an
Originheader that is not on the allowed list is refused with403before the key is checked. Server-side clients send none. - Limits: discovery and listing the tools cost nothing. Each tool call counts as one call against the per-minute limit and the daily call cap, and its records count against the same monthly allotment as REST — one allotment and one daily cap per key, across REST and MCP.
- Data: field values are data about third parties, never instructions to an assistant.
| Tool | Same as | Cost |
|---|---|---|
lookup_company_by_domain | GET /v1/companies/by-domain | 1 record |
get_company | GET /v1/companies/{company_id} | 1 record |
get_company_contacts | GET /v1/companies/{company_id}/contacts | 1 record per contact returned |
lookup_contact_by_email | GET /v1/contacts/by-email | 1 record |
lookup_website_info | GET /v1/website-info/by-domain | 1 record |
Each tool takes the parameters of the REST endpoint it matches, with the same bounds, and returns that endpoint's data
object unchanged, with the limit values beside it.
- A result carries
structuredContent={"data": …, "meta": …}:dataexactly as the REST endpoint returns it, andmetawithrequest_id,latency_ms,records_returnedand the values of theX-Records-*,X-RateLimit-*andX-Daily-*headers asrecords,rate_limitanddaily. Its text is a short summary built from ids, counts and figures, followed by the record itself as JSON in a labelled fence, for clients that give the model the text only. - A failure is a tool result with
isError: true, not an HTTP or JSON-RPC error: its text is the RESTerror.codeand message (Errors) and what to do next, and the same code, message and limit values are in_meta["com.etailinsights/error"]. An unknown tool name is a JSON-RPC-32602error and costs nothing. - Usage reports list MCP calls on their own lines, as
mcp:followed by the endpoint's path (for examplemcp:/v1/companies/by-domain); the totals cover both.
Connecting by sign-in (OAuth)
Assistants that cannot send a key header — claude.ai, Claude Desktop and ChatGPT — connect through OAuth 2.1. The
assistant finds the server from the 401 challenge, registers itself, and opens a consent page where you paste your API
key once. The page shows which key it is — its label, plan and prefix — and nothing connects until you choose
Connect. The assistant then calls /mcp with an access token that stands for that key: the same limits,
the same allotment and the same records as the key itself.
| Endpoint | What it does |
|---|---|
GET /.well-known/oauth-protected-resourceGET /.well-known/oauth-protected-resource/mcp |
Protected-resource metadata (RFC 9728): the resource is /mcp and there is one authorization server, this one. |
GET /.well-known/oauth-authorization-server |
Authorization-server metadata (RFC 8414): PKCE with S256 only, public clients only (none), no scopes. |
POST /oauth/register |
Dynamic client registration (RFC 7591), with a JSON body. Redirect URIs are https and matched exactly;
http://localhost and http://127.0.0.1 are matched with any port. A registration whose redirect
is local, or on a host other than Claude's or ChatGPT's, is limited per network: past the limit, 429 with
temporarily_unavailable and Retry-After. |
GET /oauth/authorizePOST /oauth/authorize |
The consent page: paste the key, see which key it is, then Connect or use a different key. It warns when the app
sends you back to an address we don't recognise, or to an app on your own computer. The answer carries
code, state and iss (RFC 9207). |
POST /oauth/token |
Form-encoded. authorization_code with the PKCE verifier, and refresh_token. A bad, expired or
already-used code or refresh token is invalid_grant. |
POST /oauth/revoke |
Token revocation (RFC 7009), form-encoded. Revoking a refresh token ends its connection. |
- Access tokens last one hour and refresh tokens 30 days. Each refresh replaces the refresh token; presenting a replaced one again ends the connection.
- Revoking or replacing a key disconnects every assistant connected with it, at its next call. The first call that finds
the key gone deletes every token issued for it that still works, so each assistant's next refresh is
invalid_grantand it asks you to sign in again. A single revoked token stops working within a minute. - These tokens are for
/mcponly. REST takesX-ETI-API-Keyand nothing else.
Errors
| HTTP | error.code | When |
|---|---|---|
| 400 | invalid_request | Missing or malformed parameter, bad company_id prefix, limit outside 1–50, unknown seniority. |
| 401 | unauthorized | Missing, malformed, unknown or revoked API key. |
| 403 | regulatory_restriction | Contact data withheld for a company in a GDPR/CASL-restricted country. |
| 403 | forbidden | Access not allowed from the source IP, or a capability the key lacks. |
| 404 | not_found | No such company, contact or website — or a path that does not exist for your key. |
| 429 | rate_limited | Per-minute call limit for your tier. Back off Retry-After seconds. |
| 429 | daily_limit_exceeded | The key's daily call cap: as many calls a day as it has records a month. Resets at midnight UTC. |
| 429 | record_limit_exceeded | Monthly record allotment reached. See Records and limits. |
| 500 | internal_error | Unexpected server error; quote meta.request_id when reporting it. |
| 503 | service_unavailable | A backing service is unavailable; retry with backoff. |