# POST /v1/serp/history

Position history for a (domain, keyword) pair from our SERP archive.

**Credits:** 5 credits per call.

**Timeout:** 60s

## Parameters

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `domain` | string | yes | — | Domain to check ranking history for (bare hostname, lowercased). Required, max 255 chars, no spaces. |
| `keyword` | string | yes | — | Keyword to check ranking history for. Required, max 700 chars. |
| `engine` | string | no | "google" | Optional, default "google". Same enum as /v1/search. |
| `location` | any | no | 2840 | Google Ads geotarget ID. Optional, default 2840 (United States). Look one up with /v1/locations. |
| `language` | string | no | "en" | ISO language code. Optional, default "en". |
| `device` | string | no | "desktop" | One of: desktop, mobile. Optional, default "desktop". |
| `date_from` | string | no | — | ISO YYYY-MM-DD, inclusive. Optional, default 730 days ago -- the rankings archive's TTL floor. An empty result is still a chargeable answer: coverage.observations counts the distinct days we archived this exact SERP in the window, so it can mean either "we observed you on N days and you never ranked" (observations > 0) or "we never looked" (observations: 0). |
| `date_to` | string | no | — | ISO YYYY-MM-DD, inclusive. Optional, default today. |

## Request

```http
POST /v1/serp/history HTTP/1.1
Authorization: Bearer sof_live_your_key_here
Content-Type: application/json

{
  "domain": "example.com",
  "keyword": "best running shoes",
  "engine": "google",
  "location": 2840,
  "language": "en",
  "device": "desktop",
  "date_from": "2026-05-20",
  "date_to": "2026-07-28"
}
```

## Response

Wrapped in the standard envelope (id, object, created_at, elapsed_ms, cache, credits) -- documented once on The Contract. The body below is a real envelope with this endpoint's `data` shape; values vary per request, and a `…` marks an array cut short for display.

```json
{
  "id": "serp_gwzvlrjdbirntfjvzng3axyt",
  "request_id": "req_pqktaosc65dc7eskb2h5eqigdq",
  "object": "serp_history",
  "created_at": "2026-08-09T08:27:28Z",
  "elapsed_ms": 180,
  "cache": "miss",
  "credits": {
    "charged": 5,
    "balance": 9857
  },
  "data": {
    "domain": "example.com",
    "keyword": "best running shoes",
    "engine": "google",
    "location": 2840,
    "language": "en",
    "device": "desktop",
    "points": [
      {
        "date": "2026-06-03",
        "rank": 9,
        "url": "https://example.com/best-running-shoes",
        "type": "organic"
      },
      {
        "date": "2026-06-17",
        "rank": 6,
        "url": "https://example.com/best-running-shoes",
        "type": "organic"
      }
    ],
    "first_seen": "2026-06-03",
    "best_rank": 6,
    "coverage": {
      "from": "2026-05-20",
      "observations": 14
    }
  }
}
```

## Response fields

What each field in `data` (above) means.

| Field | Description |
| --- | --- |
| `data.domain` | The domain you requested. |
| `data.keyword` | The keyword you requested. |
| `data.engine` | Search engine this history is drawn from. |
| `data.location` | Google Ads geotarget ID this history is drawn from. |
| `data.language` | ISO language code this history is drawn from. |
| `data.device` | Device this history is drawn from: desktop or mobile. |
| `data.points` | One entry per day with an archived observation for this (domain, keyword) pair, in the requested date window. |
| `data.points[].date` | Date of this observation, YYYY-MM-DD. |
| `data.points[].rank` | Absolute rank on that day's results page, or null if the domain didn't appear. |
| `data.points[].url` | URL that ranked, or null if the domain didn't appear. |
| `data.points[].type` | Result type this rank came from, e.g. "organic". |
| `data.first_seen` | Date of the earliest point in this response, or null if points is empty. |
| `data.best_rank` | Best (lowest-numbered) rank across all points, or null if the domain never appeared. |
| `data.coverage` | How much of the rankings archive this response drew from. |
| `data.coverage.from` | Earliest date the archive could have observed, floor-clamped to the archive's retention window. |
| `data.coverage.observations` | Number of distinct days we archived this exact result page in the window -- can be > 0 even when every point shows no ranking. |
