Email verification in cURL

curl is the fastest way to find out whether a problem is your code or our API. Every command below runs as written once ZAPBOUNCE_KEY is exported, and each one is the exact request the libraries on the other pages build.

Two flags are worth adding by habit. --fail-with-body makes curl exit non-zero on a 4xx while still printing the error, which is what a shell script needs. -sS keeps the progress bar out of your pipe and leaves errors visible.

curl 7.76 or later for --fail-with-body. Pipe through jq if you want the output readable.

Verify one address

You branch on four statuses: valid, invalid, catch_all and unknown. Role and disposable arrive in the flags array beside the status, so test them separately. Unknown is the branch most code forgets, and its credit is refunded.

Shell
export ZAPBOUNCE_KEY="zb_live_..."

# One address.
curl -sS --fail-with-body https://app.zapbounce.com/api/v1/verify \
  -H "X-API-Key: $ZAPBOUNCE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"emails":["ada@example.com"]}' | jq '.results[0]'

# Just the verdict, for a shell condition.
verdict=$(curl -sS https://app.zapbounce.com/api/v1/verify \
  -H "X-API-Key: $ZAPBOUNCE_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"emails\":[\"$1\"]}" | jq -r '.results[0].status')

case "$verdict" in
  valid)     echo "send" ;;
  catch_all) echo "segment separately: the domain accepts everything" ;;
  unknown)   echo "no verdict, credit refunded, try again tomorrow" ;;
  *)         echo "suppress" ;;
esac

Verify a list

Submit a job rather than looping the single endpoint. We pace probes per receiving mail host, which protects the sending reputation your results depend on.

Shell
API=https://app.zapbounce.com/api/v1

# Submit a job from a file of addresses, one per line (up to 50,000).
job_id=$(jq -R -s -c 'split("\n") | map(select(length > 0))' contacts.txt \
  | jq -c '{emails: ., source: "api"}' \
  | curl -sS --fail-with-body $API/jobs \
      -H "X-API-Key: $ZAPBOUNCE_KEY" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: $(uuidgen)" \
      -d @- | jq -r '.id')
echo "job $job_id"

# Poll until it finishes. There is no webhook.
until case "$(curl -sS $API/jobs/$job_id -H "X-API-Key: $ZAPBOUNCE_KEY" | jq -r '.status')" in
        completed|partial|failed|cancelled) true ;; *) false ;; esac; do
  sleep 10
done

# Page the rows into one JSONL file.
page=1
while :; do
  body=$(curl -sS "$API/jobs/$job_id/emails?page=$page&pageSize=100" \
    -H "X-API-Key: $ZAPBOUNCE_KEY")
  echo "$body" | jq -c '.items[]' >> results.jsonl
  [ "$page" -ge "$(echo "$body" | jq -r '.meta.totalPages')" ] && break
  page=$((page + 1))
done

# What came back unresolved (its credit was refunded).
jq -r 'select(.status == "unknown") | .email' results.jsonl | wc -l

# Or stream the whole job as CSV: email,status,reason_code,mx,smtp_code,checked_at
curl -sS --fail-with-body $API/jobs/$job_id/export \
  -H "X-API-Key: $ZAPBOUNCE_KEY" -o results.csv

Errors and retries

A 429 and a 5xx are worth retrying. A 422 CREDITS_INSUFFICIENT and a 400 for a malformed address will fail the same way on every attempt, so stop.

Shell
# --fail-with-body: non-zero exit AND the error body, which -f alone swallows.
# --max-time: a timeout (exit 28) is not a bad address, so fail open as unknown.
if ! response=$(curl -sS --fail-with-body --max-time 10 \
      https://app.zapbounce.com/api/v1/verify \
      -H "X-API-Key: $ZAPBOUNCE_KEY" \
      -H "Content-Type: application/json" \
      -d '{"emails":["ada@example.com"]}'); then
  [ $? -eq 28 ] && { echo '{"status":"unknown","reasonCode":"TIMEOUT"}'; exit 0; }
  code=$(echo "$response" | jq -r '.code')            # body: statusCode, code, message, requestId
  request_id=$(echo "$response" | jq -r '.requestId')
  echo "failed: $code (requestId=$request_id)" >&2
  [ "$code" = "CREDITS_INSUFFICIENT" ] && exit 2      # 422, retrying will not help
  exit 1
fi

# Retry with backoff on a 429. There is no Retry-After header, so wait on the
# window length from X-RateLimit-Window-Seconds.
attempt=0
while [ $attempt -lt 5 ]; do
  http_code=$(curl -sS -D /tmp/zb.headers -o /tmp/zb.json -w '%{http_code}' \
    https://app.zapbounce.com/api/v1/verify \
    -H "X-API-Key: $ZAPBOUNCE_KEY" \
    -H "Content-Type: application/json" \
    -d "{\"emails\":[\"$1\"]}")
  [ "$http_code" = "200" ] && break
  [ "$(jq -r '.code' /tmp/zb.json)" = "CREDITS_INSUFFICIENT" ] && { echo "out of credits" >&2; exit 2; }
  window=$(grep -i '^x-ratelimit-window-seconds:' /tmp/zb.headers | tr -dc '0-9')
  sleep $(( ${window:-1} * (2 ** attempt) ))
  attempt=$(( attempt + 1 ))
done
jq '.results[0]' /tmp/zb.json

cURL: common questions

Can I put the key in the URL?

We do not accept it there. Query strings land in access logs, in shell history and in the Referer header, and a header costs nothing.

How do I check my balance?

curl -sS https://app.zapbounce.com/api/v1/credits/balance -H "X-API-Key: $ZAPBOUNCE_KEY" | jq. GET /v1/credits/ledger lists the ledger.

Do I need a CLI?

No. These commands plus jq cover everything a CLI would wrap, and they work on any machine with curl already installed.

Run this cURL code today

100 free checks a month, no card, credits that never expire, and unknown results refunded.