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

Advertisement
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?
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 1,400 cities across 40+ 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 every command in this guide runs exactly as written, from a terminal or from a browser console.
The endpoint takes a country and city slug directly in the path:
# 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.icsSubstitute 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.
Advertisement
Layer 1: Does the Hostname Resolve?
Roughly a third of "the API is down" reports are DNS. 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.
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.8If two public resolvers disagree, the record is still propagating and the API will look intermittently broken depending on which resolver each client happens to hit. 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 takes 30 seconds before failing.
# Time-boxed TCP check — do not wait for the default timeout
nc -vz -w 5 waqtazan.com 443
# Or with curl, connection phase only
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, the request never completed a connection at all. That is a layer 1–3 problem, not an HTTP one. ERR_CONNECTION_REFUSED covers the refused case in detail.
Advertisement
Layer 3: Is the TLS Certificate Valid?
Port 443 is open, but an open port does not mean a working HTTPS endpoint. Certificate failures are the most common overnight breakage, because certificates 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, which cache intermediates from previously visited sites, while failing in curl, Java, Python, and Go. That is the origin of the classic "it works in my browser but not in production" bug report.
# 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/nullCount 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.
Omitting -servername is a common self-inflicted wound: without SNI, a shared host hands you the wrong certificate and you spend an hour debugging a name mismatch that does not actually 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. Fetch the headers only — you do not need the body yet.
curl -s -D - -o /dev/null https://waqtazan.com/api/v1/timings/egypt/cairoRead the status line first, then content-type, then everything else. Those two fields alone explain the majority of "the API returned something weird" reports.
Advertisement
What a Healthy JSON Response Looks Like
Running the command above against the test endpoint returns this header set:
| Header | Value | What it tells you |
|---|---|---|
| `HTTP/2` | `200` | Request succeeded, and the server speaks HTTP/2 |
| `content-type` | `application/json` | You will get JSON, not an HTML error page |
| `content-length` | `563` | A real payload, not an empty body |
| `access-control-allow-origin` | `*` | Browsers are allowed to read this response |
| `server` | `cloudflare` | A CDN sits in front of the origin |
| `alt-svc` | `h3=":443"` | HTTP/3 is advertised for subsequent requests |
Layer 5: The Header That Breaks Only Browser Calls
This layer produces the most wasted hours, 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:
// Paste into any browser console — works 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, the browser first sends an OPTIONS preflight, and the preflight response must carry its own CORS headers.
Test the preflight separately, because it is a different response from a different code path:
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/cairoAt the time of writing, 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:
{
"city": { "slug": "riyadh", "name_ar": "الرياض", "name_en": "Riyadh" },
"country": "saudi-arabia",
"date": "2026-08-24",
"times": { "fajr": "4:10 ص", "dhuhr": "11:56 ص", "maghrib": "6:20 م" },
"timesISO": { "fajr": "2026-08-24T01:10:00.000Z", "dhuhr": "2026-08-24T08:56: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.
Advertisement
Symptom → Layer → Tool
Use this to jump straight to the layer that matters, instead of walking all six every time:
| What you see | Failing layer | Check it with |
|---|---|---|
| `ERR_NAME_NOT_RESOLVED`, `ENOTFOUND` | 1 — DNS | [DNS lookup](/dns-lookup) |
| `ECONNREFUSED`, instant failure | 2 — Port | [Port checker](/port-checker) |
| Request hangs, then times out | 2 — Filtered port | [Port checker](/port-checker) |
| `CERT_HAS_EXPIRED`, `UNABLE_TO_VERIFY_LEAF_SIGNATURE` | 3 — TLS | [SSL checker](/ssl-checker) |
| Works in browser, fails in curl or Node | 3 — Incomplete chain | [SSL checker](/ssl-checker) |
| `401`, `429`, `503` | 4 — HTTP status | [HTTP headers](/http-headers) |
| Works in curl, blocked in browser | 5 — CORS | [HTTP headers](/http-headers) |
| `Unexpected token < in JSON` | 4 — HTML error page | [HTTP headers](/http-headers) |
| Fields missing or wrong type | 6 — Payload | `curl … | jq` |
Latency sits slightly outside this ladder. If every layer passes but responses are slow, measure the network path 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.
Coba HTTP Headers CheckerAdvertisement
Frequently Asked Questions
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.