> ## Documentation Index
> Fetch the complete documentation index at: https://docs.stefanbrain.com/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> Follow the content contract in AGENTS.md.
> Treat text as the canonical explanation; video and screenshots enhance it.
> Do not publish unverified product behavior or duplicate an existing canonical article.

# Meta landing page labels

> Write market labels for the landing pages of Meta ads, and check a batch with a dry run before you write it.

The Landing Page Labels API stores one label card for each landing page a Meta ad sends people to. A card holds six labels: industry, product form, target gender, target age, price band, and page kind. StefanBrain reads the cards it has made live to group cross-account Meta benchmarks by market.

Use this API only if StefanBrain has given your account the `landing_page_label_writer` grant. Every other account receives `403 landing_page_label_writer_required` from both endpoints.

Use a full-access `stefan_sk_...` key or one scoped to include `tools`, or any `stefan_oat_...` OAuth token (OAuth tokens are not scoped).

## Read the label lists

Each label is a value of a fixed list. Read the lists of one list version with `GET /api/developers/v1/meta/label-values/{taxonomyVersion}`:

```bash theme={"system"}
curl \
  -H "Authorization: Bearer stefan_sk_your_key_here" \
  "https://stefanbrain.com/api/developers/v1/meta/label-values/2"
```

```json theme={"system"}
{
  "taxonomyVersion": 2,
  "values": [
    {
      "dimension": "sector",
      "value": "beauty",
      "name": "Beauty & Personal Care",
      "definition": "Appearance-focused products where the promise is looking better, not treating illness.",
      "position": 2,
      "parentValue": null
    },
    {
      "dimension": "industry",
      "value": "beauty.hair_loss",
      "name": "Hair Loss",
      "definition": "Hair regrowth and thinning-hair offers: serums, supplements, and devices that promise more hair.",
      "position": 11,
      "parentValue": "beauty"
    }
  ]
}
```

`dimension` is one of `sector`, `industry`, `product_form`, `target_gender`, `target_age`, `price_band`, and `page_kind`. You never send a sector: it is the first part of the industry id. Build your labeling prompt from these rows so that it names the same values the write accepts.

This `GET` does not consume request limits or a billing budget. It returns `404 taxonomy_version_not_found` when no list has that version.

## Check a batch with a dry run

Send 1 to 200 items to `POST /api/developers/v1/meta/landing-page-labels`. The body is UTF-8 JSON of at most 32 MB. Set `dryRun` to `true` to run every check and store nothing:

```bash theme={"system"}
curl -X POST https://stefanbrain.com/api/developers/v1/meta/landing-page-labels \
  -H "Authorization: Bearer stefan_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "labelVersion": "estate-remap-1",
    "taxonomyVersion": 2,
    "model": "your-model-id",
    "promptVersion": "landing-page-remap-v1",
    "sourceArtifactSha256": "4f3c2b1a00000000000000000000000000000000000000000000000000000000",
    "dryRun": true,
    "items": [
      {
        "landingUrl": "https://Shop.example.com/hair-serum/?utm_source=fb",
        "inputDigest": "9a1b2c3d00000000000000000000000000000000000000000000000000000000",
        "industry": "beauty.hair_loss",
        "productForm": "topical",
        "targetGender": "female",
        "targetAge": null,
        "priceBand": null,
        "pageKind": "product_page",
        "evidence": {
          "brand": "Example Brand",
          "productName": "Hair Serum",
          "categoryText": "Hair growth serum",
          "summary": "A serum for thinning hair.",
          "proposedIndustry": null,
          "viaHop": 0
        }
      }
    ]
  }'
```

Every field is required. Send `null` for a label the page does not state, and for an evidence field you do not have.

* `landingUrl` is the ad's own raw URL. Do not normalize it. StefanBrain computes the page's landing key from it and returns the key's SHA-256 as `landingKeySha256`.
* `inputDigest` is the SHA-256 of what your reading read. The page, the `labelVersion`, and the `inputDigest` together identify a card.
* `labelVersion` names one way of reading pages. The name starts with `estate-`, followed by 1 to 57 characters of `a-z`, `0-9`, dot, underscore, and hyphen. StefanBrain keeps every other name for its own readings. The first write under a name binds it to `taxonomyVersion`, `model`, and `promptVersion`, and a name is never freed.
* `evidence` is stored with the card and is not shown in benchmarks. When the industry is `<sector>.other`, put the market you would name in `proposedIndustry`.

Two raw URLs with the same `landingKeySha256` are the same page. Use the dry run to group your raw URLs by key, then send one item for each page.

## Write labels

Send the same body with `dryRun` set to `false`. The response is `200` with one result for each item, in the order you sent them:

```json theme={"system"}
{
  "results": [
    {
      "outcome": "stored",
      "landingKeySha256": "c0ffee0000000000000000000000000000000000000000000000000000000000"
    },
    {
      "outcome": "refused",
      "reason": "unknown_value",
      "landingKeySha256": "decade0000000000000000000000000000000000000000000000000000000000",
      "field": "productForm",
      "validValues": ["supplement", "topical", "device", "food_drink", "apparel", "digital_program", "service", "other"]
    }
  ]
}
```

A refused item does not stop the other items of the request. The write is rate-limited but does not consume the API wallet or plan pool.

A stored card is not shown in benchmarks until StefanBrain makes its label version live. A card that you write under a label version that is already live is shown at once.

## Handle each item result

| `outcome` | Meaning | What to do |
| - | - | - |
| `stored` | A new card was stored. | Nothing. |
| `would_store` | Dry run only: a write would store this card. | Send the item with `dryRun` set to `false`. |
| `already_stored` | The same page, `labelVersion`, `inputDigest`, and labels are stored. Nothing changed. | Nothing. A repeated request is safe. |
| `replaced` | The same card is stored, and a newer card of the page in this `labelVersion` took its place. Nothing changed. | To state these labels again, send them as a new reading with another `inputDigest`. |
| `conflict` | The same page, `labelVersion`, and `inputDigest` are stored with other labels. Nothing was written. | Send the corrected labels with another `inputDigest`. |
| `refused` | The item broke a rule. Read `reason`. | See the reasons below. |

A stored card is never edited. A correction is a new card.

### Refusal reasons

| `reason` | Cause | What to do |
| - | - | - |
| `unknown_value` | The value of `field` is not in its list. `validValues` holds the list. | Send a value from `validValues`, or `null`. A value is never changed to a near match. |
| `no_values` | All six labels are `null`. | Leave the page out. |
| `duplicate_in_request` | Two or more items of the request are the same page. None of them is stored. | Send one item for that `landingKeySha256`. |
| `no_landing_page` | `landingUrl` has no landing key: it is empty, or only a query string or a fragment. `landingKeySha256` is `null`. | Leave the row out. |
| `url_too_long` | `landingUrl` is over 8,192 characters. `landingKeySha256` is `null`. | Leave the row out. A URL is never cut, because a cut URL is another page. |
| `evidence_too_long` | `evidence` is over 8,192 bytes as stored: JSON with the keys `brand`, `product_name`, `category_text`, `summary`, `proposed_industry`, and `via_hop`, without the fields that are `null`. | Shorten the evidence text. Evidence is never cut for you. |

## Handle request errors

These errors refuse the whole request. Nothing is written.

* `400 invalid_json`: the body is not valid UTF-8, or it is not a JSON object.
* `400 invalid_landing_page_label_request`: the body breaks the request shape. The message starts with the path of the field, such as `items.3.inputDigest`.
* `400 invalid_taxonomy_version`: the list version in the path is not a positive whole number.
* `403 api_key_scope_forbidden`: use a credential with the `tools` scope.
* `403 landing_page_label_writer_required`: the account does not hold the grant.
* `404 taxonomy_version_not_found`: no list has this `taxonomyVersion`.
* `409 label_version_mismatch`: the `labelVersion` is bound to another `taxonomyVersion`, `model`, or `promptVersion`. Send the reading under a new `labelVersion`.
* `413 request_too_large`: the body is over 32 MB. Send fewer items in each request.
* `429`: wait for the returned reset time before you send another write.

The request shape also refuses text that holds a NUL character or half of a surrogate pair, because that text cannot be stored as sent. A body in another encoding, such as Latin-1, is refused for the same reason.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.