Skip to content

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

FieldTypeDefaultWhat it does
inputstringrequiredEmail address or domain, up to 320 characters. Subdomains collapse to the registrable domain (mail.stripe.com becomes stripe.com).
skip_enrichmentbooleanfalseSkip the company profile. Returns category, email_signals and verdict in milliseconds. Use it for signup screening.
check_dnsbooleanfalseAdds 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.
refreshbooleanfalseRebuild 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_reputationbooleannot skip_enrichmentRun the third-party reputation check when the server has one configured.
include_rawbooleanfalseInclude 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

FieldMeaning
categoryOne of company, free, disposable, isp, educational, government, unknown. See the category table.
verdict.is_spamTrue for disposable domains, reputation-flagged domains and machine-generated addresses. This is the field to block on.
verdict.low_trustTrue for role, system and sales addresses (info@, noreply@, sales@). Worth a review, not a block.
verdict.reasondisposable_domain, randomized_local_part, role_based, system_generated, sales_outreach, or null.
email_signalsHeuristics 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_signalsOnly with check_dns: domain_exists, can_receive_email, has_website. Informational; does not change the verdict.
companyOnly 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.confidenceThe model's own 0 to 1 estimate. Treat anything under about 0.6 as a hint, not a fact.
cachedTrue when the answer came from the cache without calling the model.
meta.enrichment_errorSet 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

statusMeaningreason values
validThe mail server confirmed the mailbox exists.mailbox_exists
invalidMail will not reach anyone. Safe to reject.invalid_syntax, disposable_domain, no_domain, no_mx, null_mx, mailbox_not_found, mailbox_disabled
riskyMight 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
unknownThe 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 }
StatuserrorWhen
400invalid_inputEmpty or malformed input, IP addresses, single-label hosts, reserved TLDs like .local or .test.
401unauthorizedMissing, unknown or revoked key.
401turnstile_requiredA 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.
403forbiddenA demo endpoint was called from a browser page on another site. Use /v1/lookup or /v1/verify with a key.
403turnstile_failedPOST /v1/demo/session was sent a Turnstile token that Cloudflare rejected. Reload the page and solve the challenge again.
404not_foundGET /v1/domains/:domain for a domain that is not cached.
413payload_too_largeRequest body over 32 KB (512 KB when creating a CSV verification job).
429rate_limitedMore than 60 requests a minute on one account, across all its keys (6 a minute per IP on the demo). Wait retry_after_seconds.
429quota_exceededA daily budget is used up: 2,000 lookups or 300 verified addresses per account. Resets at 00:00 UTC.
500internal_errorSomething broke on our side. The body includes a request_id; send it to us.
503quota_unavailableUsage 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.

LimitValueNotes
Requests60 a minute per accountShared across all of the account's keys. Over it: 429 rate_limited.
API keys3 active per accountUp to 5 new keys a day.
Lookups2,000 a day per accountCache hits included. Over it: 429 quota_exceeded.
New company profiles50 a day per accountFresh 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 verification300 addresses a day per account/v1/verify, /v1/verify/batch and dashboard CSV uploads combined. Over it: 429 quota_exceeded.
CSV uploads50 rows, 30 columnsCells up to 256 characters.
Demo6 requests a minute per IPLookup 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

EndpointAuthNotes
GET /v1/domains/:domainAPI keyRead a cached result without triggering a lookup. 404 if not cached.
POST /v1/demo/sessionTurnstile tokenBody { 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/lookupdemo sessionWebsite 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/batchAPI keyUp to 50 addresses per call. Same result shape as /v1/verify.
POST /v1/demo/verifydemo sessionWebsite demo only: needs the x-demo-session header. Single address, 3 requests a minute per IP within the shared demo limit.
/v1/verify/jobsdashboard sessionCSV uploads from the dashboard: create, process, list, download, delete.
GET /v1/healthnoneLiveness check.
GET, POST, DELETE /v1/keysdashboard sessionUsed by the dashboard to list, create and revoke keys.