You have already made a request with curl or a playground. The jump to code is small in lines and large in consequences: a script runs unattended, so every way the call can fail has to be handled by something you wrote in advance. This lesson builds one function that does that properly, and you will reuse it for the rest of the course.
Everything here runs on Node 18 or newer, which ships fetch as a global. No package to install. The examples use ES modules (import/export and top-level await), so either name your files .mjs or add "type": "module" to package.json. Every lesson in this course sticks to that choice.
01The smallest call that works
Start with the email validator, because its response is easy to read and its answer is obviously right or wrong. Set your key in the shell first so it never appears in a file:
# macOS / Linux
export APIVERVE_API_KEY="YOUR_API_KEY"
# Windows PowerShell
$env:APIVERVE_API_KEY = "YOUR_API_KEY"Then the call:
const res = await fetch(
'https://api.apiverve.com/v1/emailvalidator?email=support@myspace.com',
{ headers: { 'x-api-key': process.env.APIVERVE_API_KEY } }
);
const body = await res.json();
console.log(body.data.isValid, body.data.isDisposable);Run it with node first-call.mjs and you should see true false. Three things happened, and each one is a place a real program goes wrong:
fetchreturned a Promise of aResponse.awaitpauses this function until the response headers have arrived. The body has not been read yet.res.json()reads the body stream to the end and parses it. That is a second wait, which is why it also needsawait. Forgetting it gives you a Promise where you expected an object, andbody.dataisundefined.- The parsed body is the APIVerve envelope:
status,erroranddata. The fields you care about live underdata.
Try the same request here before going further, and look at the full shape of data:
curl "https://api.apiverve.com/v1/emailvalidator?email=support%40myspace.com" \
-H "x-api-key: YOUR_API_KEY"Check data.isValid and data.isDisposable, then try an address at a domain you know is disposable.
That snippet is fine for a first run and wrong for anything else. It assumes the network worked, the server answered with JSON, the key was accepted, and the API found what you asked for. Each of those assumptions fails in production on a normal Tuesday.
02Two layers of success
An HTTP API reports success twice: once in the status code on the response line, and once in the body. You have to check both.
What fetch considers a failure
fetch only rejects its Promise when it could not get a response at all: DNS failure, refused connection, a dropped network, a CORS block in the browser, or an aborted request. A 401, a 429 or a 500 is a perfectly successful HTTP exchange as far as fetch is concerned. The Promise resolves, and res.ok is false.
That surprises almost everyone who has used axios, which throws on non-2xx statuses. With fetch, if you never look at res.ok, your code will happily try to read data out of an error response.
res.ok is true for any status from 200 to 299. res.status gives you the exact number when you need to branch on it.
What the envelope adds
The body carries its own verdict. A successful call looks like this:
{
"status": "ok",
"error": null,
"data": { "email": "support@myspace.com", "isValid": true, "isDisposable": false }
}
A failed one keeps the same three keys, with status set to "error", data set to null, and error holding a sentence written for a human, such as "The 'email' parameter is required.". That sentence names the problem, so it is the message you want to surface, log or show.
Why check status when you already checked res.ok? Because the two can disagree. The batch endpoint returns 200 for a batch where individual items failed, and treating "the HTTP exchange worked" as "I have my answer" is the habit that causes that bug. Make the envelope check part of every call, and the multi-result paths will not catch you out later.
When the body is not JSON
There is one more case to plan for: a response that isn't the JSON envelope at all. APIVerve answers its own errors in JSON, but anything between you and the API can answer instead: a proxy, a captive Wi-Fi portal, a gateway timing out with an HTML error page, or the edge rejecting a header that is absurdly long. Calling res.json() on an HTML page throws a SyntaxError, and the error you see ("Unexpected token '<'") points nowhere near the real cause.
Key problems themselves come back as JSON, and the most common one is an environment variable that was never set: process.env.APIVERVE_API_KEY is undefined, the header goes out as the string "undefined", and the API answers 401. The short version of the key-related responses:
| You get | It means |
|---|---|
JSON 401 | No key was sent, or the key isn't recognised. Check the environment variable, then that you copied the current key. |
JSON 403 | The key is valid but not allowed to make this call (scoping, IP allow-list, revoked). |
So the parse step needs a guard: try to read JSON, and if that fails, fall back to the status code.
Your code calls fetch() on an APIVerve endpoint with an invalid but well-formed key. What happens to the Promise fetch returns?
03A reusable helper
Every call you make in this course goes through one function. Writing it once means the checks above happen every time, not just in the call you were careful with.
const BASE = 'https://api.apiverve.com/v1';
export class ApiError extends Error {
constructor(message, status) {
super(message);
this.name = 'ApiError';
this.status = status;
}
}
export async function apiverve(id, params = {}) {
const key = process.env.APIVERVE_API_KEY;
if (!key) throw new Error('APIVERVE_API_KEY is not set');
const url = new URL(`${BASE}/${id}`);
for (const [name, value] of Object.entries(params)) {
url.searchParams.set(name, String(value));
}
const res = await fetch(url, { headers: { 'x-api-key': key } });
let body = null;
try {
body = await res.json();
} catch {
// Not JSON: something other than the API answered (a proxy, a gateway error page).
}
if (!res.ok || body?.status !== 'ok') {
throw new ApiError(body?.error || `HTTP ${res.status}`, res.status);
}
return body.data;
}Using it:
import { apiverve, ApiError } from './apiverve.mjs';
try {
const data = await apiverve('numbertowords', { number: 1999 });
console.log(data.words); // "one thousand, nine hundred ninety-nine"
} catch (err) {
if (err instanceof ApiError) {
console.error(`API said no (${err.status}): ${err.message}`);
} else {
console.error('Could not reach the API:', err.message);
}
}A few decisions in that helper are worth defending, because you will be tempted to undo them.
The key check runs before the request. Without it, a missing variable does not fail at startup; it fails at the first call, as a 401, in production. Failing loudly with the variable's name saves the hour you would spend reading network traces.
Parameters go through URL and searchParams. Building the query string by concatenation works until someone looks up "R&D" or an email with a + in it. searchParams.set encodes each value correctly. Hand-concatenated query strings are one of the most common bugs in beginner API code, and the failure is silent: the API receives a different value than you meant and answers a different question.
The function returns data, not the whole body. Callers almost never want the envelope once it has been checked, and returning only data means nobody downstream can forget to check it.
Errors are thrown, not returned. Returning null on failure forces every caller to remember to test for it. Throwing an ApiError with the status lets callers that care branch on it, and lets callers that do not care let it propagate to a single handler. Lesson three uses the status field to decide what is safe to retry.
Here is the call the helper makes, if you want to compare its output with yours:
curl "https://api.apiverve.com/v1/numbertowords?number=1999" \
-H "x-api-key: YOUR_API_KEY"data.words is the cardinal form. Try a decimal like 2048.5 and look at afterDecimal.
04Data types coming back
JSON has numbers, strings, booleans, arrays, objects and null, and you get exactly what the API sent. Two habits prevent the subtle bugs:
- Read the type from the reference page, not from the value. Some APIs return numeric values as strings (exchange rates are a common example) so that precision survives.
"0.89" * 100works by coercion in JavaScript;"0.89" + 1gives"0.891". Convert explicitly withNumber()when you need arithmetic. - Treat
nullas a real answer. A field can benullbecause the value is genuinely unknown for that input, or because your plan does not include it. Optional chaining (data.afterDecimal?.words) keeps one missing value from throwing.
05The same code in a browser
fetch in the browser is the same function with the same Promise behaviour, so everything above carries over: res.ok, the envelope check, the JSON guard. Only one line cannot move: where the key comes from.
There is no process.env in a browser. Bundlers will happily inline an environment variable into your JavaScript at build time if you ask them to, and that is the problem. Anything the browser executes, the person using the browser can read: in the bundle, in the Network tab, in a saved HAR file. A key shipped to the browser is a published key, and anyone who copies it spends your credits.
So browser code should call your own server, and the server adds the key. The browser side then looks like this:
async function numberToWords(n) {
const url = new URL('/api/numbertowords', window.location.origin);
url.searchParams.set('number', String(n));
const res = await fetch(url);
let body = null;
try { body = await res.json(); } catch {}
if (!res.ok || body?.status !== 'ok') {
throw new Error(body?.error || `HTTP ${res.status}`);
}
return body.data;
}No key, same checks. Building that /api/... endpoint, in Express and as a Cloudflare Worker, is the whole of lesson four.
One more browser rule: do not add credentials: 'include' to these requests. The API answers with a wildcard Access-Control-Allow-Origin, and browsers refuse to combine that with credentialed requests, so the option turns a working call into a CORS failure.
A teammate puts the API key in a build-time environment variable so the React app can call APIVerve directly. Why is that still unsafe?
06Common bugs, in the order you will hit them
- Forgetting the second
await.const body = res.json()gives you a Promise. Every property read on it isundefined. - Checking only
res.ok. Fine today, wrong the first time you fan out with batch. - Calling
res.json()twice. The body is a stream and can be read once. If you need it as text for logging and as JSON, read it withres.text()andJSON.parsethe string yourself. - Logging the request with its headers. That puts your key into whatever collects your logs. Log the URL and the status, never the
x-api-keyvalue. - No timeout.
fetchwill wait as long as the connection stays open. That is the first thing lesson three fixes.
07Where this leaves you
You now have apiverve(id, params): it reads the key from the environment, encodes parameters safely, checks the HTTP status and the envelope, survives a non-JSON body, and throws an error that carries the status. That is a sound base, but it still trusts the network to answer promptly and never stumble.
Next, the same helper in Python with requests, including sessions and the params versus json distinction: Call an API with Python requests.