API reference
One endpoint does the work: POST /v1/lookup. Send an email address or a domain and get back its category, spam verdict, email signals and, for company domains, a company profile. Base URL https://pingutil.com. Everything is JSON.
Want to click instead of read? The interactive explorer runs requests from your browser, and the OpenAPI 3 spec works with any client generator. For copy-paste examples in several languages see the quickstart.
Authentication
Send your key as a bearer token: Authorization: Bearer pk_live_…. Create keys on the dashboard after signing in with your email (no password). The full key is shown once; we store only a SHA-256 hash. Keep it on your server. Never ship it in browser or mobile code.
POST /v1/lookup
curl -X POST https://pingutil.com/v1/lookup \
-H "Authorization: Bearer $PINGUTIL_KEY" \
-H "Content-Type: application/json" \
-d '{"input": "jane@stripe.com"}'GET /v1/lookup?input=jane@stripe.com&skip_enrichment=true works too, with the same fields as query parameters.
Request fields
| Field | Type | Default | What it does |
|---|---|---|---|
input | string | required | Email address or domain, up to 320 characters. Subdomains collapse to the registrable domain (mail.stripe.com becomes stripe.com). |
skip_enrichment | boolean | false | Skip the company profile. Returns category, email_signals and verdict in milliseconds. Use it for signup screening. |
check_dns | boolean | false | Adds domain_signals: does the domain exist, can it receive mail, does it have a website. Resolved over DNS-over-HTTPS and cached for 7 days. |
refresh | boolean | false | Rebuild the company profile instead of using the cache. Counts against the 50 new profiles a day. Repeated refreshes within 60 seconds return the cached row. |
check_reputation | boolean | not skip_enrichment | Run the third-party reputation check when the server has one configured. |
include_raw | boolean | false | Include the raw model output in meta.raw. Useful for debugging. |
Response
{
"domain": "stripe.com",
"domain_unicode": "stripe.com",
"category": "company",
"subcategory": null,
"is_email": true,
"input_normalized": "jane@stripe.com",
"cached": true,
"fetched_at": "2026-10-01T08:00:00.000Z",
"last_refreshed_at": "2026-09-28T12:34:56.000Z",
"company": {
"name": "Stripe",
"industry": "Financial Services",
"founded_year": 2010,
"headquarters": { "city": "South San Francisco", "region": "CA", "country": "US" },
"employee_size_estimate": "5001-10000",
"funding_stage": "series_d_plus",
"linkedin_url": "https://www.linkedin.com/company/stripe",
"confidence": 0.9
},
"email_signals": {
"is_randomized": false,
"randomness_score": 12,
"is_role_based": false,
"is_system_generated": false,
"is_sales_outreach": false,
"domain_looks_random": false
},
"domain_signals": null,
"verdict": { "is_spam": false, "low_trust": false, "reason": null },
"meta": {
"source": "@cf/openai/gpt-oss-20b",
"categorization_source": "fallthrough",
"categorization_confidence": "medium",
"request_id": "req_..."
}
}The company object is trimmed above. The full list of company fields is below.
Response fields
| Field | Meaning |
|---|---|
category | One of company, free, disposable, isp, educational, government, unknown. See the category table. |
verdict.is_spam | True for disposable domains, reputation-flagged domains and machine-generated addresses. This is the field to block on. |
verdict.low_trust | True for role, system and sales addresses (info@, noreply@, sales@). Worth a review, not a block. |
verdict.reason | disposable_domain, randomized_local_part, role_based, system_generated, sales_outreach, or null. |
email_signals | Heuristics on the part before the @: is_randomized and randomness_score (0 to 100), is_role_based, is_system_generated, is_sales_outreach, domain_looks_random. Null when you send a bare domain. |
domain_signals | Only with check_dns: domain_exists, can_receive_email, has_website. Informational; does not change the verdict. |
company | Only for company domains, otherwise null. Fields: name, legal_name, tagline, description, industry, sub_industry, tech_categories, founded_year, headquarters, employee_size_estimate, website, linkedin_url, twitter_handle, crunchbase_url, github_org, parent_company, funding_stage, total_funding_usd, notable_customers, is_public, ticker, confidence. |
company.confidence | The model's own 0 to 1 estimate. Treat anything under about 0.6 as a hint, not a fact. |
cached | True when the answer came from the cache without calling the model. |
meta.enrichment_error | Set when no fresh profile was built: an error, "domain_unresolvable" (the domain doesn't resolve in DNS) or "daily_enrichment_limit" (the account's 50 new profiles for the day are used up). The request still returns 200 with the category, and meta.served_stale is true if an older profile was returned. |
Profiles come from an AI model filling a fixed schema. By default the Worker fetches the company's homepage and gives it to gpt-oss-20b on Cloudflare Workers AI as evidence; meta.grounding_sources lists the page it used. The server can also run Gemini with Google Search grounding. Validated output is cached per domain, so the first lookup of a new company takes a few seconds and later ones are fast.
POST /v1/verify
Checks whether a single mailbox exists. It runs the syntax and disposable-domain checks, finds the domain's mail server, then opens an SMTP conversation and asks about the address with RCPT TO. It hangs up before sending anything. It also probes a random address on the same domain to spot catch-all servers. Results are cached for 30 days (one hour for unknown), and the cache stores a keyed SHA-256 hash (HMAC) of the address, not the address itself.
curl -X POST https://pingutil.com/v1/verify \
-H "Authorization: Bearer $PINGUTIL_KEY" \
-H "Content-Type: application/json" \
-d '{"email": "jane@stripe.com"}'{
"email": "jane@stripe.com",
"input": "jane@stripe.com",
"status": "valid",
"reason": "mailbox_exists",
"score": 95,
"did_you_mean": null,
"checks": {
"syntax": true, "disposable": false, "free_provider": false,
"role_based": false, "randomized": false,
"domain_exists": true, "has_mx": true,
"mailbox": "exists", "catch_all": false
},
"mx_host": "aspmx.l.google.com",
"smtp": { "code": 250, "enhanced": "2.1.5", "message": "2.1.5 OK" },
"cached": false,
"checked_at": "2026-10-21T10:00:00.000Z"
}GET /v1/verify?email=… works too. To check up to 50 addresses in one call, send {"emails": [...]} to POST /v1/verify/batch; you get {"results": [...]} back in the same order. Most checks take one to five seconds; a slow mail server can take up to 25.
Verification statuses
| status | Meaning | reason values |
|---|---|---|
| valid | The mail server confirmed the mailbox exists. | mailbox_exists |
| invalid | Mail will not reach anyone. Safe to reject. | invalid_syntax, disposable_domain, no_domain, no_mx, null_mx, mailbox_not_found, mailbox_disabled |
| risky | Might exist, but sending is a gamble: the domain accepts every address, it is a shared role mailbox, the mailbox is full, or the address looks machine-generated. | accept_all, role_based, mailbox_full, randomized_local_part |
| unknown | The mail server would not answer clearly. Retry later, or treat it as risky. | greylisted, blocked, rate_limited, timeout, connection_failed, smtp_error, dns_error, smtp_unavailable |
score (0–100) orders results within a status. did_you_mean is set when the domain looks like a typo of a big provider (gmial.com → gmail.com). Some providers, Yahoo among them, accept every address during the SMTP conversation and bounce later. Those come back as risky / accept_all rather than valid.
Errors and limits
Every error has the same shape:
{ "error": "rate_limited", "message": "retry after 60s", "retry_after_seconds": 60 }| Status | error | When |
|---|---|---|
| 400 | invalid_input | Empty or malformed input, IP addresses, single-label hosts, reserved TLDs like .local or .test. |
| 401 | unauthorized | Missing, unknown or revoked key. |
| 401 | turnstile_required | A demo endpoint was called without a valid x-demo-session (missing, expired or issued to another IP). The demo is for the website only; from code, use /v1/lookup or /v1/verify with a key. |
| 403 | forbidden | A demo endpoint was called from a browser page on another site. Use /v1/lookup or /v1/verify with a key. |
| 403 | turnstile_failed | POST /v1/demo/session was sent a Turnstile token that Cloudflare rejected. Reload the page and solve the challenge again. |
| 404 | not_found | GET /v1/domains/:domain for a domain that is not cached. |
| 413 | payload_too_large | Request body over 32 KB (512 KB when creating a CSV verification job). |
| 429 | rate_limited | More than 60 requests a minute on one account, across all its keys (6 a minute per IP on the demo). Wait retry_after_seconds. |
| 429 | quota_exceeded | A daily budget is used up: 2,000 lookups or 300 verified addresses per account. Resets at 00:00 UTC. |
| 500 | internal_error | Something broke on our side. The body includes a request_id; send it to us. |
| 503 | quota_unavailable | Usage limits are temporarily unavailable, so the request was refused. Retry shortly. |
In a signup flow, decide up front what happens when the API is unreachable or rate-limited. Failing open (let the signup through, check later) is the usual choice.
| Limit | Value | Notes |
|---|---|---|
| Requests | 60 a minute per account | Shared across all of the account's keys. Over it: 429 rate_limited. |
| API keys | 3 active per account | Up to 5 new keys a day. |
| Lookups | 2,000 a day per account | Cache hits included. Over it: 429 quota_exceeded. |
| New company profiles | 50 a day per account | Fresh LLM profiles, including refresh=true. Cached results don't count. Over it, the lookup still returns 200 with meta.enrichment_error = "daily_enrichment_limit" and any older cached profile. Domains that don't resolve in DNS never get a profile (meta.enrichment_error = "domain_unresolvable"). |
| Email verification | 300 addresses a day per account | /v1/verify, /v1/verify/batch and dashboard CSV uploads combined. Over it: 429 quota_exceeded. |
| CSV uploads | 50 rows, 30 columns | Cells up to 256 characters. |
| Demo | 6 requests a minute per IP | Lookup and verify combined; verify also 3 a minute. IPv6 is counted per /64. Never refreshes cached profiles. |
The demo also has daily budgets shared by all anonymous visitors: 50 new company profiles and 150 mailbox checks. Past those it still answers, from cache for lookups and DNS-only for verification. The demo is protected by Cloudflare Turnstile: the page solves a challenge, exchanges it at POST /v1/demo/session for a 30-minute session bound to the visitor's IP, and sends it as the x-demo-session header. Requests without a valid session get 401 turnstile_required, and browsers on other sites get a 403, so curl and server code should use /v1/lookup or /v1/verify with a key.
Other endpoints
| Endpoint | Auth | Notes |
|---|---|---|
GET /v1/domains/:domain | API key | Read a cached result without triggering a lookup. 404 if not cached. |
POST /v1/demo/session | Turnstile token | Body { turnstile_token }. Returns { token, expires_in, required }: a 30-minute demo session bound to your IP. 403 turnstile_failed if the challenge fails. |
POST /v1/demo/lookup | demo session | Website demo only: needs the x-demo-session header (401 turnstile_required without it). Same body as /v1/lookup, 6 requests a minute per IP (shared with demo verify). Never refreshes cached profiles. |
POST /v1/verify/batch | API key | Up to 50 addresses per call. Same result shape as /v1/verify. |
POST /v1/demo/verify | demo session | Website demo only: needs the x-demo-session header. Single address, 3 requests a minute per IP within the shared demo limit. |
/v1/verify/jobs | dashboard session | CSV uploads from the dashboard: create, process, list, download, delete. |
GET /v1/health | none | Liveness check. |
GET, POST, DELETE /v1/keys | dashboard session | Used by the dashboard to list, create and revoke keys. |