DEV Community

dodou
dodou

Posted on

Reading the SERP API Response Envelope: request_id, elapsed_ms, credits_charged

Reading the SERP API Response Envelope: request_id, elapsed_ms, credits_charged

Every SERP API response you get back has more in it than the results. Alongside the organic list, each response carries a small set of envelope fields — status, request_id, elapsed_ms, credits_charged, search_type — that most people ignore until something breaks. Once you start logging them, they answer three questions you'd otherwise be guessing at: which call failed, how long it actually took, and what it cost.

I added these to a monitoring job last week after a "slow API" complaint turned out to be one endpoint returning 900ms while the rest sat under 300ms. The envelope told me in one query what I'd have spent an afternoon guessing. Field definitions are in the SerpBase search endpoint docs.

What's in the envelope

The envelope sits at the top level of every response, next to the data key for the endpoint you called (organic for search, places for maps, and so on):

  • status — 0 on success, non-zero on error. This is the field to branch on before you touch the payload.
  • request_id — a stable identifier for the request. When you need support to trace a specific call, this is the number they'll ask for.
  • elapsed_ms — the request duration in milliseconds, measured server-side.
  • credits_charged — the credits deducted for this request. The docs describe it as "Credits charged after refund logic is applied."
  • search_type — which search type the request resolved to, useful when your routing decides between endpoints dynamically.

The search response also carries query and page as required fields, so you can echo back what you actually asked for rather than assuming your parameters round-tripped.

The envelope is your instrumentation

Here's a client wrapper that logs the envelope on every call instead of discarding it:

import json, logging, time
from pathlib import Path

import requests

API = "https://api.serpbase.dev/google/search"
KEY = "your_api_key"   # X-API-Key header

log = logging.getLogger("serp")
logging.basicConfig(level=logging.INFO, format="%(message)s")

def search(q: str, hl: str = "en", gl: str = "us", page: int = 1,
           device: str = "default") -> dict:
    """Returns (payload, envelope); logs both for every call."""
    resp = requests.post(API,
        headers={"X-API-Key": KEY, "Content-Type": "application/json"},
        json={"q": q, "hl": hl, "gl": gl, "page": page, "device": device},
        timeout=30)
    data = resp.json()

    env = {k: data.get(k) for k in
           ("status", "request_id", "elapsed_ms", "credits_charged", "search_type")}
    log.info(json.dumps({"q": q, "page": page, **env}, ensure_ascii=False))

    if data.get("status") != 0:
        raise RuntimeError(f"status={env['status']} request_id={env['request_id']}")
    return data

def envelope_report(logfile: Path) -> dict:
    """Aggregate a run of logged calls into cost/latency/failure numbers."""
    calls = [json.loads(line) for line in logfile.read_text("utf-8").splitlines() if line.strip()]
    ok = [c for c in calls if c["status"] == 0]
    return {
        "calls": len(calls),
        "failures": len(calls) - len(ok),
        "credits_spent": sum(c.get("credits_charged") or 0 for c in calls),
        "p50_ms": sorted(c["elapsed_ms"] for c in ok)[len(ok) // 2] if ok else None,
        "slowest_q": max(ok, key=lambda c: c["elapsed_ms"])["q"] if ok else None,
        "search_types": sorted({c["search_type"] for c in ok if c.get("search_type")}),
    }

if __name__ == "__main__":
    for kw in ["serp api pricing", "rank tracker python"]:
        search(kw, page=1)
        search(kw, page=2)
    print(envelope_report(Path("serp_envelope.log")))
Enter fullscreen mode Exit fullscreen mode

Three things this buys you:

  1. Latency without a stopwatch. elapsed_ms is measured server-side, so it excludes your process scheduling, DNS, and local queueing. When you compare it against your own client-side timing you can tell whether a slow run is the API or your own code.
  2. Cost per run, not per guess. Summing credits_charged across a batch gives you the actual bill for that batch. If a keyword list costs more than you expected, the log says which query types drove it — images and maps endpoints charge 2 credits per request while search, news and videos charge 1.
  3. A trace handle for support. Keep request_id next to your own job ID. When a response looks wrong, you can point at one specific call instead of "it happened sometime Tuesday".

What the envelope does not promise

One thing worth being precise about, because it changes how you budget: the docs describe credits_charged as credits charged after refund logic is applied, and the billing section says "executed failures may be charged". The pricing page adds that timeouts and unknown executions are not automatically refunded.

In practice that means a request that reached the upstream and came back with an error can still have consumed credits. So don't treat credits_charged as "credits for successful results only" — treat it as the authoritative number for what you actually spent, and read it from the response rather than assuming 1 credit per call. For a batch job, the pre-flight balance check from GET /account/credits (0 credits, 60 requests/minute) plus post-run reconciliation against the sum of credits_charged is the combination that catches surprises.

FAQ

Is status the only field I need to check? For correctness, yes — branch on status != 0 before parsing the payload. For operating the thing, the other four are what turn "the API feels slow this week" into a number.

Does elapsed_ms include my network latency? No. It's the server-side duration. Compare it to your own time.monotonic() delta around the call if you want to see the network and client overhead separately.

Can I trust credits_charged to be 1 for search? Search is listed at 1 credit per request, but the field is the authoritative record after refund logic. Read it from the response instead of hardcoding the assumption into your accounting.

What's search_type for? It reports which search type the request resolved to. If you route between endpoints dynamically, it's a cheap assertion that the server agreed with your intent.

Add the envelope logging to your next batch script — five fields, one log line per call, and your cost and latency questions get answered by the data instead of by memory.

Top comments (0)