emailverifier.dev
Posts

How to Check Email Domains From a Cloudflare Worker

| 4 min read | Usama Ejaz
Cloudflare Worker domain check cover with an email inside a globe and a check mark.

This tutorial builds and deploys a Cloudflare Worker that accepts a domain, protects the endpoint with an internal token, calls the emailverifier.dev domain API once, and returns provider classification, mail-routing, and risk evidence.

The result is intentionally domain-level. It can tell you about example.com, but it cannot prove whether person@example.com exists.

Scaffold the Worker

Create a TypeScript Worker with Cloudflare's current project generator:

npm create cloudflare@latest email-domain-check
cd email-domain-check

Choose a Worker application when prompted. The tutorial uses this structure:

email-domain-check/
├─ src/index.ts
└─ wrangler.jsonc

Create both secrets

Create an emailverifier.dev project and copy its API key. Then add it to the Worker:

npx wrangler secret put EMAILVERIFIER_API_KEY
npx wrangler secret put INTERNAL_TOKEN

Use a separate random value for INTERNAL_TOKEN. Trusted server callers send it as a bearer token so the public Worker URL cannot consume credits anonymously.

Configure Wrangler

Replace wrangler.jsonc with:

{
  "$schema": "./node_modules/wrangler/config-schema.json",
  "name": "email-domain-check",
  "main": "src/index.ts",
  "compatibility_date": "2026-08-15",
  "compatibility_flags": ["nodejs_compat"],
  "secrets": {
    "required": [
      "EMAILVERIFIER_API_KEY",
      "INTERNAL_TOKEN"
    ]
  },
  "observability": {
    "enabled": true
  }
}

Generate the binding types from the configuration:

npx wrangler types

Declaring required secret names makes missing production bindings fail clearly. Their values remain encrypted secrets and never belong in vars. Cloudflare documents this field in the Wrangler configuration reference.

Implement the Worker

Replace src/index.ts with:

type DomainResult = {
  domain: string
  classification: 'disposable' | 'free_provider' | 'unknown'
  risk: 'high' | 'medium' | 'unknown'
  mail_routing: 'available' | 'unavailable' | 'unknown'
}

const domainPattern =
  /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i

function textBytes(value: string) {
  return new TextEncoder().encode(value)
}

function tokensMatch(received: string, expected: string) {
  const left = textBytes(received)
  const right = textBytes(expected)

  return left.byteLength === right.byteLength &&
    crypto.subtle.timingSafeEqual(left, right)
}

export default {
  async fetch(request, env): Promise<Response> {
    const url = new URL(request.url)

    if (request.method !== 'GET' ||
        url.pathname !== '/check-domain') {
      return Response.json(
        { error: 'Not found.' },
        { status: 404 }
      )
    }

    const authorization =
      request.headers.get('Authorization') ?? ''
    const token = authorization.startsWith('Bearer ')
      ? authorization.slice(7)
      : ''

    if (!tokensMatch(token, env.INTERNAL_TOKEN)) {
      return Response.json(
        { error: 'Unauthorized.' },
        { status: 401 }
      )
    }

    const domain = (url.searchParams.get('domain') ?? '')
      .trim()
      .toLowerCase()

    if (!domainPattern.test(domain)) {
      return Response.json(
        { error: 'A valid domain is required.' },
        { status: 400 }
      )
    }

    let response: Response
    try {
      response = await fetch(
        'https://emailverifier.dev/api/v1/domains/' +
          encodeURIComponent(domain),
        {
          headers: {
            'X-API-Key': env.EMAILVERIFIER_API_KEY
          },
          signal: AbortSignal.timeout(5000)
        }
      )
    } catch {
      return Response.json(
        { error: 'Domain check is temporarily unavailable.' },
        { status: 503 }
      )
    }

    if (!response.ok) {
      return Response.json(
        { error: 'Domain check is temporarily unavailable.' },
        { status: 503 }
      )
    }

    const result = (await response.json()) as DomainResult
    return Response.json(result)
  }
} satisfies ExportedHandler<Env>

The endpoint accepts only GET /check-domain. Invalid domains stop before the external request, and the project key stays inside the Worker binding.

Run it locally

Wrangler uses local secret values from .dev.vars. Create that untracked file:

EMAILVERIFIER_API_KEY=replace_with_your_project_key
INTERNAL_TOKEN=replace_with_a_random_internal_token

Start the Worker:

npm run dev

Call the local endpoint with the same internal token:

curl "http://localhost:8787/check-domain?domain=example.com" \
  -H "Authorization: Bearer replace_with_a_random_internal_token"

A successful response has this shape:

{
  "domain": "example.com",
  "classification": "unknown",
  "risk": "unknown",
  "mail_routing": "unavailable"
}

The values above are an example of the response schema, not a permanent classification for the domain. Provider lists and DNS can change.

Exercise the failure paths

  • Remove the bearer token and confirm the Worker returns 401.
  • Use ?domain=not-a-domain and confirm it returns 400 without spending a credit.
  • Remove the local API key and confirm the upstream branch returns 503.
  • Use a valid domain and confirm the response includes classification, risk, and mail_routing.

classification can be disposable, free_provider, or unknown. risk can be high, medium, or unknown. mail_routing can be available, unavailable, or unknown. The REST API reference is the source of truth for these values.

Deploy the Worker

npm run deploy

Repeat the curl request against the deployed URL. The tutorial is complete when an authenticated domain request returns the three evidence fields and the same request without the internal token returns 401.

Use the public email domain reports to inspect the same domain-level concepts before deciding whether the Worker should classify a domain or verify a complete address.

Use the full email verification endpoint instead when signup policy needs syntax, typo, role-address, catch-all, or recipient evidence. A domain result cannot answer those questions.

Continue reading