How to Test a Public API Endpoint: DNS, TLS, Headers & CORS

Key takeaway

"The API is down" is almost never one problem. Test it in six layers (DNS, port 443, TLS, HTTP status, CORS, payload) and stop at the first layer that fails. Typical culprits are a DNS record that never propagated, a certificate that expired overnight, or a missing Access-Control-Allow-Origin header that breaks browser calls while curl works fine.

Check it now

Diagram of the six layers to test a public API endpoint: DNS resolution, port 443, TLS certificate, HTTP headers, CORS, and JSON payload
Diagram of the six layers to test a public API endpoint: DNS resolution, port 443, TLS certificate, HTTP headers, CORS, and JSON payload
Start reading
  1. Why "The API Is Down" Is Never One Problem
  2. Pick a Real Endpoint You Can Hammer
  3. Layer 1: Does the Hostname Resolve?
  4. Layer 2: Is Port 443 Open?
  5. Layer 3: Is the TLS Certificate Valid?
  6. Layer 4: Read the Response Headers
  7. What a Healthy JSON Response Looks Like
  8. Layer 5: The Header That Breaks Only Browser Calls
  9. The Preflight Trap: Works Until You Add a Header
  10. Layer 6: Is the Payload the Shape You Expect?
  11. Symptom → Layer → Tool
  12. Frequently Asked Questions

Why "The API Is Down" Is Never One Problem

When an API call fails, the error your client shows you is usually generated several layers above the thing that actually broke. A browser reporting Failed to fetch cannot tell you whether the hostname failed to resolve, the TCP connection was refused, the TLS handshake was rejected, or the server returned a perfectly healthy 200 that the browser then discarded for a missing CORS header. All four produce the same message.

The fix is to stop guessing and walk the stack in order. Each layer only makes sense if the one beneath it succeeded, so the first layer that fails is your actual bug — everything after it is noise.

There are six layers worth checking, and together they take about two minutes:

  • DNS — does the hostname resolve to an IP at all?
  • Port 443 — is anything listening, or is the connection refused or filtered?
  • TLS — is the certificate valid, unexpired, and served with a complete chain?
  • HTTP status — what does the server actually return: 200, 401, 429, 503?
  • CORS — does the response carry the headers a browser needs to hand you the body?
  • Payload — is the JSON the shape your code expects?

Check an endpoint's status and headers now

Pick a Real Endpoint You Can Hammer

To practise this you need a live endpoint that returns real data, needs no API key, and will not rate-limit you for running the same request twenty times. That rules out most obvious candidates — nearly every mainstream public API now gates you behind a signup form and a token, which means a failed request could always be your credentials rather than the layer you are testing.

The examples below use the free prayer-times API from Waqt Azan (مواقيت الصلاة), an Arabic prayer-times service covering more than 60,000 places in over 100 countries. It works well as a teaching endpoint for three reasons: there is no key and no auth header, it returns structured JSON rather than HTML, and it sets a permissive CORS policy — so the curl commands run exactly as written, and the fetch() example works from the console of a page without a strict Content-Security-Policy.

The endpoint takes a country and city slug directly in the path:

bash
# Prayer times for a city — no API key, no headers required
curl -s https://waqtazan.com/api/v1/timings/saudi-arabia/riyadh

# The same data as a subscribable calendar feed
curl -s https://waqtazan.com/api/v1/ical/saudi-arabia/riyadh.ics

Substitute any host you are actually debugging — the six layers are identical whether you are testing a public API, your own staging server, or a third-party webhook receiver.

Layer 1: Does the Hostname Resolve?

DNS is a common culprit. A record was changed and has not propagated, a wildcard was removed, or the API lives on a subdomain that was never created in the first place.

Resolve the hostname before anything else. If this step returns nothing, no amount of restarting your app will help — your client never gets far enough to open a socket.

bash
dig +short waqtazan.com A
nslookup waqtazan.com

# Compare what different resolvers see — catches mid-propagation records
dig +short waqtazan.com A @1.1.1.1
dig +short waqtazan.com A @8.8.8.8

If two public resolvers disagree just after you changed a record, it is still propagating, and the API will look intermittently broken depending on which resolver each client happens to hit (a geo-routing CDN can hand each resolver a different address all the time, so compare against the record you set). Run a DNS lookup to see the full record set, or check propagation across global resolvers from the DNS propagation checker right after changing a record.

An empty answer here maps directly to ERR_NAME_NOT_RESOLVED in Chrome and getaddrinfo ENOTFOUND in Node — ERR_NAME_NOT_RESOLVED has the full fix list.

One thing not to worry about: many APIs sit behind a CDN, so the A record points at a CDN edge rather than the origin server. That is expected. What matters is that you get an answer at all, not which IP it is.

Layer 2: Is Port 443 Open?

The hostname resolves, so something has an address. Now find out whether anything is listening on it. This is where you separate three very different failures that clients love to report identically:

  • Open — the TCP handshake completes. Move to the next layer.
  • Refused — the host answered with an immediate RST. Something is there, but nothing is bound to that port — usually the service crashed, or is bound to localhost only.
  • Filtered — the connection hangs until it times out. A firewall or security group is dropping packets silently, which is why your request hangs for a minute or more before failing (75 seconds on macOS).
bash
# Time-boxed TCP check: -w limits the connect on Linux; on macOS use -G 5 instead
nc -vz -w 5 waqtazan.com 443

# Or with curl: --connect-timeout caps the connect; 000 means no response came back
curl -s -o /dev/null -w '%{http_code}\n' --connect-timeout 5 https://waqtazan.com/

A refused connection and a filtered one need completely different fixes, so it is worth knowing which you have. The port checker distinguishes them and tests from outside your network — which matters, because a port that is open from your office VPN may be firewalled off from the public internet.

If curl prints 000 as the status code, no HTTP response came back. Usually the request failed in layers 1–3 (no DNS answer, a refused or filtered port, a failed TLS handshake), but a server that accepts the connection and then closes it or never answers also gives 000; add -v to see the last step that worked. ERR_CONNECTION_REFUSED covers the refused case in detail.

Layer 3: Is the TLS Certificate Valid?

Port 443 is open, but an open port does not mean a working HTTPS endpoint. Certificates are a classic overnight breakage, because they expire on a schedule and renewal automation fails quietly.

The certificate needs three things to be true at once: it is not expired, its name matches the hostname you requested, and the server sends the full intermediate chain. The third is the sneaky one: an incomplete chain often works in browsers, because Chrome downloads a missing intermediate itself and Firefox preloads them, while Node, Python, Java and OpenSSL builds of curl fail (the curl that ships with macOS uses Apple's verifier and may pass). That is the origin of the classic "it works in my browser but not in production" bug report.

bash
# Expiry dates and subject
echo | openssl s_client -connect waqtazan.com:443 -servername waqtazan.com 2>/dev/null \
  | openssl x509 -noout -subject -dates

# Full chain as presented by the server
openssl s_client -connect waqtazan.com:443 -servername waqtazan.com -showcerts < /dev/null

Count the certificates in the -showcerts output. A single certificate on its own usually means a missing intermediate. The SSL certificate checker reports expiry, chain completeness, and hostname match in one pass, and what an SSL certificate chain is explains why the intermediate matters so much.

Keep -servername: OpenSSL 1.1.1 and later fill in SNI from the -connect hostname, but the LibreSSL openssl that ships with macOS doesn't, and no version does when you connect to an IP address. Without SNI a shared host hands you its default certificate, or, like Cloudflare, aborts the handshake, and you chase a name mismatch that doesn't exist.

Layer 4: Read the Response Headers

TLS is good, so you finally have a real HTTP conversation. Now read what the server actually said, instead of what your client summarised for you. Print only the headers: the body is still downloaded and thrown away, so this is the same GET your client sends.

bash
curl -s -D - -o /dev/null https://waqtazan.com/api/v1/timings/egypt/cairo

Read the status line first, then content-type, then everything else. Those two fields tell you whether the request succeeded and whether the body is JSON before you read anything else.

What a Healthy JSON Response Looks Like

Running the command above against the test endpoint on 27 October 2026 returned these headers, among others:

HeaderValueWhat it tells you
HTTP/2200Request succeeded, and the server speaks HTTP/2
content-typeapplication/jsonYou will get JSON, not an HTML error page
content-length567A real payload, not an empty body
access-control-allow-origin*Browsers are allowed to read this response
servercloudflareA CDN sits in front of the origin
alt-svch3=":443"HTTP/3 is advertised for subsequent requests

Layer 5: The Header That Breaks Only Browser Calls

This layer is where hours disappear, because it is the one where curl and the browser genuinely disagree. CORS is enforced by the browser, not the server. If Access-Control-Allow-Origin is missing, the server still returns a complete, correct 200 — the browser simply refuses to hand the body to your JavaScript.

So the symptom is: curl works perfectly, Postman works perfectly, and the browser console says the request was blocked by CORS policy. Nothing is wrong with the server. The response is just missing one header.

The test endpoint returns access-control-allow-origin: *, which is why a plain fetch() from any origin works:

javascript
// Run it in the console of a tab on a site without a strict CSP, such as example.com
// (this page's own CSP blocks it). It works there because ACAO is set to *
const r = await fetch('https://waqtazan.com/api/v1/timings/egypt/cairo')
console.log(await r.json())

The Preflight Trap: Works Until You Add a Header

There is a second-order gotcha, and this endpoint demonstrates it nicely. A simple request (a plain GET with no custom headers) succeeds. But the moment you add a custom header, or POST with Content-Type: application/json, the browser first sends an OPTIONS preflight, and the preflight response must carry its own CORS headers (MDN).

Test the preflight separately, because it is a different response from a different code path:

bash
curl -s -D - -o /dev/null -X OPTIONS \
  -H 'Origin: https://example.com' \
  -H 'Access-Control-Request-Method: GET' \
  https://waqtazan.com/api/v1/timings/egypt/cairo

As of 27 October 2026, that preflight returns a 200 with content-type: text/html and no access-control-allow-origin header at all, so a preflighted request from a browser would be blocked, even though the plain GET works fine.

That is the exact trap: your integration works in development, you add an Authorization or X-Request-Id header, and it breaks with no server-side change whatsoever. If you only need public data, keep the request simple and it will keep working.

Layer 6: Is the Payload the Shape You Expect?

The last layer is the one your code actually cares about. A 200 with valid JSON can still break you if a field was renamed, a type changed from number to string, or a timestamp arrives in a format you do not parse.

Look at the real response before writing the parser. This endpoint returns both display-formatted times and ISO timestamps, which is a pattern worth copying — the localised strings are for humans, the ISO values are for code:

json
{
  "city": { "slug": "riyadh", "name_ar": "الرياض", "name_en": "Riyadh" },
  "country": "saudi-arabia",
  "date": "2026-10-26",
  "times":    { "fajr": "4:38 ص", "dhuhr": "11:37 ص", "maghrib": "5:17 م" },
  "timesISO": { "fajr": "2026-10-26T01:38:00.000Z", "dhuhr": "2026-10-26T08:37:00.000Z" },
  "method": "umm_al_qura"
}

Always parse the machine-readable field. Here that means timesISO, never times — the display strings are localised and will change format the moment the locale does.

Pipe the response through jq to inspect structure quickly: curl -s <url> | jq keys lists the top-level fields, and jq '.times' drills in without scrolling through the raw body.

Symptom → Layer → Tool

Use this to jump straight to the layer that matters, instead of walking all six every time:

What you seeFailing layerCheck it with
ERR_NAME_NOT_RESOLVED, ENOTFOUND1 — DNSDNS lookup
ECONNREFUSED, instant failure2 — PortPort checker
Request hangs, then times out2 — Filtered portPort checker
CERT_HAS_EXPIRED, UNABLE_TO_VERIFY_LEAF_SIGNATURE3 — TLSSSL checker
Works in browser, fails in curl or Node3 — Incomplete chainSSL checker
401, 429, 5034 — HTTP statusHTTP Header Checker
Works in curl, blocked in browser5 — CORSHTTP Header Checker
Unexpected token '<', "<!DOCTYPE "... is not valid JSON4 — HTML error pageHTTP Header Checker
Fields missing or wrong type6 — Payloadcurl … | jq

Latency sits slightly outside this ladder. If every layer passes but responses are slow, time the round trip to the host with ping and check whether name resolution itself is the bottleneck with a DNS speed test.

Check any endpoint's real response headers

See the exact status code, content-type, and CORS headers an API returns — requested server-side, so no browser cache or extension can distort the result. Free, no signup.

Sources

  1. Cross-Origin Resource Sharing (CORS), MDN
  2. Fetch Standard: CORS-safelisted request headers, WHATWG

Frequently Asked Questions

How do I test an API endpoint without Postman?

curl covers everything Postman does for basic testing. Use curl -s -D - -o /dev/null <url> to see only the response headers, curl -s <url> | jq to inspect the JSON body, and curl -s -o /dev/null -w '%{http_code}' to print just the status code. For a quick check with no terminal at all, an online HTTP headers checker returns the same information from a browser.

Why does my API work in curl but fail in the browser?

Almost always CORS. The server returns a valid response either way, but browsers refuse to expose the body to JavaScript unless the response carries an Access-Control-Allow-Origin header permitting your origin. curl ignores CORS entirely, because CORS is a browser security policy rather than a server one. If the header is missing, the server has to send it — there is no client-side fix.

What does curl returning status code 000 mean?

It means no HTTP response was ever received, so there is no status code to report. Usually the failure happened before HTTP: DNS did not resolve, the TCP connection was refused or filtered, or the TLS handshake failed. A server that accepts the connection and then closes it or never replies gives 000 too, so run the command again with -v to see the last step that worked.

How can I tell whether an API is down for everyone or just me?

Test from outside your own network. A port checker and DNS lookup that run server-side remove your local resolver, VPN, corporate firewall, and browser cache from the equation. If the endpoint answers from an external checker but not from your machine, the problem is local — typically DNS caching or a firewall rule.

Why does an API return 200 but my JSON parser throws an error?

You are almost certainly receiving HTML with a 200 status. Captive portals, proxies, and some error pages return a success code with an HTML body. Check the content-type header: if it says text/html rather than application/json, the body is a web page and the parse error is a symptom, not the cause.

What is a CORS preflight and when does it happen?

A preflight is an automatic OPTIONS request the browser sends before the real one whenever the request is not 'simple': it uses a method other than GET, HEAD or POST, sets custom headers such as Authorization, or sends a Content-Type other than form data or plain text (application/json counts). The preflight response must itself include the CORS headers. This is why an integration can work fine until you add an auth header, then break with no server change.

Should I test the API's DNS with my own resolver or a public one?

Both, and compare them. Your ISP resolver may serve a stale cached record long after the authoritative record changed. Querying 1.1.1.1 and 8.8.8.8 alongside your default resolver shows whether you are looking at a propagation delay or a genuinely missing record.

Does an open port 443 mean the HTTPS endpoint works?

No. An open port only confirms that something accepted a TCP connection. The TLS handshake can still fail because the certificate expired, the hostname does not match, or the intermediate chain is incomplete. Check the certificate separately after confirming the port is open.

About the author

Shaik Vahid

Founder of DNS Robot. Vahid spent years in web hosting support fixing domains, DNS records, SSL certificates and email delivery for customers. He builds DNS Robot's tools and writes these guides from the errors people actually hit.

HTTP Header Checker

Response headers exactly as sent, with a Mozilla Observatory security grade