I was doing keyword research for WebKeeper with DataForSEO, the pay-as-you-go SEO data API. The plan needed about 150 Google result pages: the top ten organic results for each candidate keyword, so I could see who ranks and how strong they are. DataForSEO's request body is always a JSON array of tasks, so I did the obvious thing and sent them in batches of 25.

The script ran without an error. Every request came back 200. The top-level status said 20000, "Ok." At the end, I had 6 result pages out of 150.

The error was inside the response

DataForSEO wraps every call in an envelope with its own status, and every task in the array gets another status of its own. The envelope was fine. The tasks weren't. Abbreviated, and rebuilt from the run log because I'd already deleted the cached responses:

{
  "status_code": 20000,
  "status_message": "Ok.",
  "tasks_count": 25,
  "tasks_error": 24,
  "tasks": [
    { "status_code": 20000, "status_message": "Ok.", "result": [ ... ] },
    { "status_code": 40000, "status_message": "You can set only one task at a time.", "result": null },
    ...
  ]
}

The first task in each batch ran. The other 24 failed with 40000, which DataForSEO's error appendix describes as exactly what it says: "you can set only one task at a time." Six batches, six results, 144 failures. The failed tasks weren't billed, so the only cost was the time it took to notice.

The Live SERP documentation does say it, once: "each Live SERP API call can contain only one task." What misled me is that the shape of the request doesn't. The body is an array on every endpoint, including the ones that accept exactly one element, and a POST endpoint that accepts "no more than 100 tasks at a time" lives right next to it.

Why nothing complained

My client checked the envelope, which is what you'd check on most APIs:

if (!response.ok || json.status_code !== 20000) throw new Error(...);

Both conditions passed. It printed a warning per failed task, but the run finished, wrote its cache and produced a report from 6 keywords. Worse, the cache stored the whole response, failures included. A rerun would have read the same 24 failures back from disk and never asked DataForSEO again.

This is the same trap I wrote about with PostHog's cookieless mode: a 200 Ok that only means the request was accepted. The status you need is one level down.

The fix

Three changes, all small.

Send one task per Live request. For 150 keywords that's 150 requests, which is well inside the documented 2,000 calls per minute:

for (const keyword of keywords) {
  const r = await call('serp/google/organic/live/regular', [
    { keyword, location_code: 2840, language_code: 'en', device: 'desktop', depth: 10 },
  ]);
  const task = r.tasks?.[0];
  if (task?.status_code !== 20000) continue;
  // ... read task.result[0].items
}

Check every task, not just the envelope, and don't cache a response that contains a failed one, so the next run asks again:

const failed = (json.tasks ?? []).filter((t) => t.status_code !== 20000);
for (const t of failed) console.warn(`${endpoint}: ${t.status_code} ${t.status_message}`);
if (failed.length === 0) await writeCache(file, json);

If you actually want batches, use the queue. task_post accepts up to 100 tasks per request, and the standard queue costs $0.0006 per result page against $0.002 for Live. The catch is latency: you post, poll tasks_ready, and fetch with task_get a few minutes later. For research that runs once, the queue is the better deal. I stayed on Live because I wanted the numbers in the same run.

Then a 402, with money in the account

With one task per request, the second call failed outright:

serp/google/organic/live/regular failed: HTTP 402 Payment Required.

DataForSEO documents 402 and 40200 as "payment required": the balance can't cover the request. My balance was $48.85. I had deposited $50 less than an hour earlier and spent about $2. The rate limits are separate codes, 40202 for more than 2,000 calls a minute and 40209 for more than 30 simultaneous requests, and my calls were sequential, one at a time.

I retried the same request by hand a few seconds later. It went through, 20000, $0.002.

My first thought was that "Payment Required" meant paying for a faster tier. It doesn't look like it. DataForSEO has no tiers, only a prepaid balance, and its rate-limit page says limits don't vary by plan; you raise them by asking support. The account's own user_data, read two minutes after the 402, reported the standard 2,000 requests a minute and a $1,000 daily spending cap I was nowhere near.

So the client got a retry: on 402 or 429, wait 5 seconds, then 15, then 45, and give up after three attempts. Here's how often it fired:

Run Live SERP calls Calls that got a 402
First run, English, right after the deposit about 150 5, one of them twice
Nine more markets over the next day about 730 0

Every 402 cleared on the first 5-second wait but one, which needed a second wait of 15 seconds. All of them happened in the first hour after the deposit, and none after that.

What I don't know

I don't know why a funded account got 402. The timing fits a fresh deposit that hadn't fully settled on DataForSEO's side, but that is a guess from one account and one deposit, not something the docs or support told me. If you see a 402 on a live endpoint, check the balance before you assume anything:

GET https://api.dataforseo.com/v3/appendix/user_data

If money.balance is positive, a short backoff worked for me. If it isn't, retrying just burns time; top up.

If your DataForSEO results are missing

  • Read tasks[].status_code, not only the envelope. tasks_error in the envelope tells you how many failed.
  • 40000 You can set only one task at a time. means a Live endpoint got more than one task. Send one per request, or move to task_post.
  • 40101 Internal SE Server Error. is the search engine failing, not you. I saw one in about 880 calls; a rerun fixed it.
  • Don't let a cache or a retry layer store partial failures as if they were results.

Takeaway

On DataForSEO the HTTP status and the envelope tell you the request arrived. Whether it worked is in tasks[], one status per task. Check it there, and the whole research run for ten markets cost me $13.42 instead of a day of wondering why the report was so short.