Domain lookup API quickstart: curl, JavaScript, Python, Go
· 6 min read · Tutorials
To look up a domain or email address with pingutil, send POST https://pingutil.com/v1/lookup with a Bearer API key and a JSON body like { "input": "jane@stripe.com" }. You get back a category (company, free, disposable, educational, government, isp or unknown), a verdict you can block on, and for company domains a structured company profile. Below are working examples in curl, JavaScript, Python and Go, each with a timeout and rate-limit handling.
Get a key, or use the demo endpoint first
Create a free key in the dashboard at pingutil.com/dashboard (passwordless sign-in). The raw key starts with pk_live_ and is shown once, so put it in your secret manager straight away. Keys are limited to 60 requests per minute.
If you just want to see the response shape, the demo endpoint needs no key and allows 10 requests per minute per IP:
curl -X POST https://pingutil.com/v1/demo/lookup \
-H "Content-Type: application/json" \
-d '{ "input": "test@mailinator.com" }'
You can also paste inputs into the live demo on the homepage. Don't ship the demo endpoint in production code: the per-IP limit is shared with everything behind your server's IP.
curl: the two request forms
The POST form is the one to use from code:
export PINGUTIL_KEY="pk_live_..."
curl -X POST https://pingutil.com/v1/lookup \
-H "Authorization: Bearer $PINGUTIL_KEY" \
-H "Content-Type: application/json" \
-d '{ "input": "jane@stripe.com" }'
The GET form is handy in a browser tab or a shell one-liner. Flags become query parameters with the string value true:
curl "https://pingutil.com/v1/lookup?input=jane@stripe.com&skip_enrichment=true" \
-H "Authorization: Bearer $PINGUTIL_KEY"
The request flags that matter
| Flag | Default | Use it when |
|---|---|---|
skip_enrichment |
false |
You only need category, email_signals and verdict. No LLM call, so it returns in milliseconds. Signup screening should almost always set this. |
check_dns |
false |
You want domain_signals (domain_exists, can_receive_email, has_website). Adds a DNS-over-HTTPS lookup, cached 7 days. |
check_reputation |
on for full lookups, off with skip_enrichment |
You want the reputation check even in fast mode. |
refresh |
false |
A cached company profile looks out of date. Debounced to once per 60 seconds per domain. |
include_raw |
false |
You're debugging enrichment and want the provider output in meta.raw. |
Input can be an email or a bare domain, up to 320 characters. pingutil lowercases it, converts internationalized domains to punycode and keys everything on the registrable domain, so mail.stripe.com and stripe.com return the same record.
Fields you should read in the response
A trimmed response for jane@stripe.com:
{
"domain": "stripe.com",
"category": "company",
"is_email": true,
"cached": true,
"company": { "name": "Stripe", "industry": "Financial Services", "confidence": 0.95 },
"email_signals": { "is_randomized": false, "randomness_score": 12, "is_role_based": false },
"verdict": { "is_spam": false, "low_trust": false, "reason": null },
"domain_signals": null,
"meta": { "request_id": "req_..." }
}
verdict.is_spam: the field to block on. True for disposable domains, reputation-flagged domains and randomized local parts.verdict.low_trustandverdict.reason: role, system and sales addresses. Route or review these rather than rejecting them.category: use it for routing (consumer vs business vs student) more than for blocking.company:nullunlesscategoryiscompanyand enrichment ran. Checkcompany.confidence; it's the model's own estimate and can be wrong.meta.enrichment_error/meta.served_stale: enrichment failed, the request still returned 200 with whatever was available.
The full schema, including every company field, is in the API reference.
Errors and how to treat each one
Every error has the same body: { "error": "<code>", "message": "..." }, plus retry_after_seconds on a 429.
| Status | error |
What your code should do |
|---|---|---|
| 400 | invalid_input |
The input is malformed, an IP literal, or a reserved TLD like .test. Treat it as a bad address. Don't retry. |
| 401 | unauthorized |
Missing, wrong or revoked key. A config bug: alert, don't retry. |
| 429 | rate_limited |
Stop calling for retry_after_seconds, then resume. |
| 5xx | internal_error |
Retry once with a short delay, then give up. Log request_id. |
Timeouts and network errors never produce a status at all, so every client below sets an explicit timeout. Use a short one (1 to 3 seconds) with skip_enrichment: true. Full lookups on a first-seen company domain can take several seconds while the profile is built, so give those 15 seconds or so.
JavaScript (Node 18+, Bun, Deno, browsers via your backend)
Native fetch and AbortSignal.timeout are all you need:
const PINGUTIL_URL = 'https://pingutil.com/v1/lookup';
export async function lookup(input, { fast = true, timeoutMs } = {}) {
const res = await fetch(PINGUTIL_URL, {
method: 'POST',
headers: {
authorization: `Bearer ${process.env.PINGUTIL_KEY}`,
'content-type': 'application/json',
},
body: JSON.stringify({ input, skip_enrichment: fast }),
signal: AbortSignal.timeout(timeoutMs ?? (fast ? 2000 : 15000)),
});
const body = await res.json().catch(() => ({}));
if (res.ok) return body;
const err = new Error(body.message ?? `pingutil HTTP ${res.status}`);
err.status = res.status;
err.code = body.error;
err.retryAfterSeconds = body.retry_after_seconds;
throw err;
}
// Process a list politely: sequential, and sleep on 429.
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export async function lookupMany(inputs) {
const results = [];
for (const input of inputs) {
for (;;) {
try {
results.push({ input, data: await lookup(input) });
break;
} catch (err) {
if (err.status === 429) {
await sleep((err.retryAfterSeconds ?? 60) * 1000);
continue;
}
results.push({ input, error: err.code ?? err.name });
break;
}
}
}
return results;
}
AbortSignal.timeout rejects with a DOMException named TimeoutError, which lands in the same catch as network failures. Never call this from client-side code with your key in it; keep the key on the server.
Python (requests)
import os
import time
import requests
PINGUTIL_URL = "https://pingutil.com/v1/lookup"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['PINGUTIL_KEY']}"
class PingutilError(Exception):
def __init__(self, status, code, message, retry_after=None):
super().__init__(f"{status} {code}: {message}")
self.status = status
self.code = code
self.retry_after = retry_after
def lookup(value, fast=True):
timeout = 2 if fast else 15
resp = session.post(
PINGUTIL_URL,
json={"input": value, "skip_enrichment": fast},
timeout=(3, timeout), # (connect, read) seconds
)
if resp.ok:
return resp.json()
try:
body = resp.json()
except ValueError:
body = {}
raise PingutilError(
resp.status_code,
body.get("error"),
body.get("message", resp.reason),
body.get("retry_after_seconds"),
)
def lookup_with_backoff(value, fast=True, max_waits=3):
for _ in range(max_waits + 1):
try:
return lookup(value, fast)
except PingutilError as e:
if e.status != 429:
raise
time.sleep(e.retry_after or 60)
raise RuntimeError("still rate limited after backing off")
if __name__ == "__main__":
data = lookup("jane@stripe.com")
print(data["category"], data["verdict"])
Without a timeout, requests will wait indefinitely, so always pass one. requests raises requests.Timeout and requests.ConnectionError for the failures that never reach the API. Catch them where you decide policy, not inside lookup.
Go (standard library only)
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
type Verdict struct {
IsSpam bool `json:"is_spam"`
LowTrust bool `json:"low_trust"`
Reason *string `json:"reason"`
}
type Company struct {
Name *string `json:"name"`
Industry *string `json:"industry"`
Confidence *float64 `json:"confidence"`
}
type LookupResponse struct {
Domain string `json:"domain"`
Category string `json:"category"`
IsEmail bool `json:"is_email"`
Cached bool `json:"cached"`
Company *Company `json:"company"`
Verdict Verdict `json:"verdict"`
}
type APIError struct {
Status int `json:"-"`
Code string `json:"error"`
Message string `json:"message"`
RetryAfterSeconds int `json:"retry_after_seconds"`
}
func (e *APIError) Error() string {
return fmt.Sprintf("pingutil %d %s: %s", e.Status, e.Code, e.Message)
}
var client = &http.Client{Timeout: 15 * time.Second}
func Lookup(ctx context.Context, input string, fast bool) (*LookupResponse, error) {
payload, _ := json.Marshal(map[string]any{"input": input, "skip_enrichment": fast})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://pingutil.com/v1/lookup", bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("PINGUTIL_KEY"))
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, err // timeout, DNS failure, connection reset
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
apiErr := &APIError{Status: resp.StatusCode}
_ = json.NewDecoder(resp.Body).Decode(apiErr)
return nil, apiErr
}
var out LookupResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
return &out, nil
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
res, err := Lookup(ctx, "jane@stripe.com", true)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(res.Category, res.Verdict.IsSpam)
}
The client-level Timeout is a ceiling; the per-call context.WithTimeout sets the real budget. Use errors.As(err, &apiErr) and check apiErr.Status == 429 to sleep for apiErr.RetryAfterSeconds in a loop.
Where to go from here
- For signup screening, keep
skip_enrichment: trueand block onverdict.is_spam. The reasoning behind blocking versus flagging is in Should you block disposable emails or just flag them?. - If you call the API on every signup, cache results per registrable domain on your side as well. Caching domain lookups covers keys and TTLs.
- Framework-specific guides: Next.js server actions and Cloudflare Workers.
One limit to keep in mind: there is no batch endpoint and no SDK package. The snippets above are the whole client, which is also why you can read every line of it.
Questions people ask
- Do I need an API key to try the domain lookup API?
- No. POST /v1/demo/lookup works without a key and is limited to 10 requests per minute per IP. For anything in production, create a free key in the dashboard and use /v1/lookup, which allows 60 requests per minute per key.
- Why does the first lookup for a company domain take a few seconds?
- The first time pingutil sees a company domain it runs LLM enrichment to build the company profile, then caches it. Later lookups for the same registrable domain come from cache. Set skip_enrichment to true if you only need the category and spam verdict.
- Is there a batch endpoint for looking up many domains at once?
- No. Each request takes one input. For a list, loop over it, stay under 60 requests per minute per key, and back off for retry_after_seconds when you get a 429.