Skip to content

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_trust and verdict.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: null unless category is company and enrichment ran. Check company.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

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.