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

# Provision a property

> Create an own brand, link its competitors and check your markets in one call, and handle a response that was only partly applied.

A **property** is an own brand: a brand the organisation owns and tracks. `POST /orgs/{orgId}/properties` creates the own brand, links its competitors and checks the markets you plan to track, in one call. Use it when you onboard a brand from your own system — as a platform partner adding a client's property, or as an agency adding a client brand.

<Note>
  The call needs a key with the `write` scope. A partner key also needs a `read_write` link to the organisation. Without them the API answers `403 write_scope_required` or `403 link_scope_insufficient`, before it reads the body. See [Authentication](/api/authentication).
</Note>

## What one call does

The API works through the request in this order:

1. It validates the body.
2. It checks that nothing blocks the new brand: the domain, your `external_ref`, and the markets against the organisation's country cap.
3. It creates the own brand. The plan's brand cap is checked here.
4. It links the competitors one at a time, in the order you sent them.

A refusal in steps 1 to 3 rejects the whole request and leaves nothing behind. Step 4 is different: it can stop part-way, and the brand from step 3 stays. [Partial success](#partial-success) explains how to read that response.

## Request body

<ParamField body="name" type="string" required>
  The property's display name. 1 to 200 characters. Leading and trailing whitespace is trimmed.
</ParamField>

<ParamField body="domain" type="string" required>
  The property's website. Send a host name or a URL, up to 2048 characters.

  The value is normalised before it is stored or compared: it is lower-cased, and the scheme, a leading `www.`, the path and the port are removed. `https://www.Example.com/` and `example.com` are the same brand. What remains must be a valid host name such as `example.com`. Anything else is `400 validation_error` with `details[].code` set to `invalid_domain`.
</ParamField>

<ParamField body="geography" type="string or null">
  A hint for the property's primary market, for example `GB`. Free text, 1 to 100 characters after trimming. Send `null`, or leave the field out, for no value; an empty string is refused. It is stored on the brand and is not checked against the list of supported markets. It does not decide where prompts are scanned — each prompt carries its own countries.
</ParamField>

<ParamField body="external_ref" type="string or null">
  Your own identifier for the property, for example `PROP-01342`. 1 to 255 characters after trimming, and unique within the organisation — compared without regard to case. Send `null`, or leave the field out, for no value; an empty string is refused. It comes back as `external_ref` on the brand, and as `brand_external_ref` in webhook events.
</ParamField>

<ParamField body="competitors" type="object[]" default="[]">
  The competitors to track for this property. At most 100 entries per request. That is a limit on the size of one request. It is not your plan's competitor cap, which applies as the competitors are linked.
</ParamField>

<ParamField body="competitors[].name" type="string" required>
  The competitor's display name. 1 to 200 characters.
</ParamField>

<ParamField body="competitors[].domain" type="string" required>
  The competitor's website, normalised in the same way as the property's `domain`. A competitor on the property's own domain is `400 validation_error` with `details[].code` set to `competitor_is_self`.
</ParamField>

<ParamField body="markets" type="string[]" default="[]">
  The countries you intend to run prompts in, as ISO 3166-1 alpha-2 codes such as `GB`. Case does not matter; codes are upper-cased and duplicates are dropped. The list cannot be longer than the list of markets Surfais scans. A code Surfais does not scan is `400 validation_error` with `details[].code` set to `unsupported_country`. See [Countries & markets](/setup/countries).
</ParamField>

<Info>
  **Markets are validated and echoed back, but not stored.** Countries belong to prompts: the organisation starts tracking a market only when an active prompt lists it. Send `markets` to learn, before anything is created, whether the plan has room for the countries you are about to use in your prompts.
</Info>

Only `name` and `domain` are required. Unknown fields are ignored.

## Send the request

Send an `Idempotency-Key` header with the call. If the connection drops before you see the response, send the same request again with the same key. When the first attempt has completed, the API replays its answer instead of running the request a second time.

<Warning>
  Reuse a key only to retry the **same** request after a timeout, a dropped connection, a `5xx` or a `409 idempotency_key_in_flight`. A `4xx` answer is stored under the key and replayed. So after a refusal that you have fixed — a freed cap slot, a corrected body — send the request under a **new** key. See [Idempotent requests](/api/idempotency).
</Warning>

The cURL sample prints the response with its headers. The Python and Node.js samples then sort the answer into one of three outcomes: created, partly created, or already there.

<CodeGroup>
  ```bash cURL theme={null}
  ORG_ID="7b1e6c1a-3f52-4d0e-9a57-2c8f1e0b4d11"
  # One key per provisioning attempt. Send the same value again only to retry this
  # exact request after a timeout, a dropped connection or a 5xx. After a 4xx that
  # you have fixed, generate a new key.
  IDEMPOTENCY_KEY="$(uuidgen)"

  curl -sS -i -X POST "https://api.surfais.com/v1/orgs/$ORG_ID/properties" \
    -H "Authorization: Bearer $SURFAIS_API_KEY" \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
    -d '{
      "name": "Hotel Aurora",
      "domain": "hotel-aurora.example",
      "geography": "GB",
      "external_ref": "PROP-01342",
      "competitors": [
        { "name": "Grand Meridian", "domain": "grand-meridian.example" },
        { "name": "Harbour Court", "domain": "harbour-court.example" }
      ],
      "markets": ["GB", "IE"]
    }'
  ```

  ```python Python theme={null}
  import os
  import uuid

  import requests

  BASE_URL = "https://api.surfais.com/v1"
  ORG_ID = "7b1e6c1a-3f52-4d0e-9a57-2c8f1e0b4d11"

  property_body = {
      "name": "Hotel Aurora",
      "domain": "hotel-aurora.example",
      "geography": "GB",
      "external_ref": "PROP-01342",
      "competitors": [
          {"name": "Grand Meridian", "domain": "grand-meridian.example"},
          {"name": "Harbour Court", "domain": "harbour-court.example"},
      ],
      "markets": ["GB", "IE"],
  }

  # One key per provisioning attempt. Send the same value again only to retry this
  # exact request after a timeout, a dropped connection or a 5xx. After a 4xx that
  # you have fixed, generate a new key.
  idempotency_key = str(uuid.uuid4())

  response = requests.post(
      f"{BASE_URL}/orgs/{ORG_ID}/properties",
      headers={
          "Authorization": f"Bearer {os.environ['SURFAIS_API_KEY']}",
          "Idempotency-Key": idempotency_key,
      },
      json=property_body,
      timeout=60,
  )
  body = response.json()

  if response.status_code == 201:
      print("created:", body["data"]["id"])
  else:
      error = body["error"]
      # `details` is a list on a validation error and an object on the others.
      details = error.get("details") if isinstance(error.get("details"), dict) else {}

      if "brand_id" in details:
          # Partial success: the brand exists. Do not send this request again.
          print("created, with competitors still to link:", details["brand_id"])
          for competitor in details["competitors"]:
              if competitor["status"] != "applied":
                  reason = competitor.get("error", {}).get("code", "not attempted")
                  print(" ", competitor["domain"], "-", reason)
      elif "existing_id" in details:
          # brand_exists or external_ref_exists: nothing was created.
          print("already there:", details["existing_id"])
      else:
          raise SystemExit(
              f"{response.status_code} {error['code']}: {error['message']} "
              f"(request {body['request_id']})"
          )
  ```

  ```javascript Node.js theme={null}
  // provision.mjs — run with Node 18 or later: node provision.mjs
  import { randomUUID } from "node:crypto";

  const BASE_URL = "https://api.surfais.com/v1";
  const ORG_ID = "7b1e6c1a-3f52-4d0e-9a57-2c8f1e0b4d11";

  const propertyBody = {
    name: "Hotel Aurora",
    domain: "hotel-aurora.example",
    geography: "GB",
    external_ref: "PROP-01342",
    competitors: [
      { name: "Grand Meridian", domain: "grand-meridian.example" },
      { name: "Harbour Court", domain: "harbour-court.example" },
    ],
    markets: ["GB", "IE"],
  };

  // One key per provisioning attempt. Send the same value again only to retry this
  // exact request after a timeout, a dropped connection or a 5xx. After a 4xx that
  // you have fixed, generate a new key.
  const idempotencyKey = randomUUID();

  const response = await fetch(`${BASE_URL}/orgs/${ORG_ID}/properties`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.SURFAIS_API_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": idempotencyKey,
    },
    body: JSON.stringify(propertyBody),
  });
  const body = await response.json();

  if (response.status === 201) {
    console.log("created:", body.data.id);
  } else {
    const { error } = body;
    // `details` is a list on a validation error and an object on the others.
    const details =
      error.details && !Array.isArray(error.details) ? error.details : {};

    if (details.brand_id) {
      // Partial success: the brand exists. Do not send this request again.
      console.log("created, with competitors still to link:", details.brand_id);
      for (const competitor of details.competitors) {
        if (competitor.status !== "applied") {
          const reason = competitor.error?.code ?? "not attempted";
          console.log(" ", competitor.domain, "-", reason);
        }
      }
    } else if (details.existing_id) {
      // brand_exists or external_ref_exists: nothing was created.
      console.log("already there:", details.existing_id);
    } else {
      throw new Error(
        `${response.status} ${error.code}: ${error.message} (request ${body.request_id})`,
      );
    }
  }
  ```
</CodeGroup>

### The `201` response

`data` is the brand in full — the same object `GET /orgs/{orgId}/brands/{brandId}` returns — plus `markets`.

```json theme={null}
{
  "data": {
    "id": "c2a4e8f0-6b1d-4c3a-8e5f-9d7b2a1c4e60",
    "name": "Hotel Aurora",
    "domain": "hotel-aurora.example",
    "is_own": true,
    "external_ref": "PROP-01342",
    "geography": "GB",
    "sunset_at": null,
    "created_at": "2026-09-21T09:14:07Z",
    "updated_at": "2026-09-21T09:14:07Z",
    "competitors": [
      {
        "id": "a41d9c6e-0b2f-4f3a-8c75-9e1d2b3a4c5d",
        "name": "Grand Meridian",
        "domain": "grand-meridian.example"
      },
      {
        "id": "d3b5f7a9-2c4e-4a6b-9d8f-0e1c2b3d4f5a",
        "name": "Harbour Court",
        "domain": "harbour-court.example"
      }
    ],
    "last_scan_at": null,
    "last_scan_changed_at": "2026-09-21T09:14:08Z",
    "next_scheduled_scan": null,
    "markets": {
      "accepted": ["GB", "IE"],
      "currently_used": ["GB"],
      "cap": 4
    }
  }
}
```

<ResponseField name="markets.accepted" type="string[]">
  The markets you sent, upper-cased and with duplicates removed. Every one of them fits within the country cap.
</ResponseField>

<ResponseField name="markets.currently_used" type="string[]">
  The distinct markets the organisation's active prompts already held when the request arrived.
</ResponseField>

<ResponseField name="markets.cap" type="integer or null">
  The organisation's effective country cap. `null` when caps are not enforced for the organisation. The number in the example is illustrative; the organisation's current cap is whatever this field reports.
</ResponseField>

`last_scan_at` stays `null` until the results of the property's first scan are final. [The data model](/api/data-model) describes the other brand fields.

## Refused before anything is created

Each of these answers means that no brand and no competitor was created. Branch on `error.code`, not on the status: this endpoint uses `409` and `422` for refusals that created nothing *and* for a partial success that did.

| Status | `error.code`           | When                                                                                                                                  | `details`                                                                                                                                                                            |
| ------ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `400`  | `validation_error`     | A field failed validation.                                                                                                            | A list with one entry per problem, each with `path`, `code` and `message`. The codes specific to this endpoint are `invalid_domain`, `unsupported_country` and `competitor_is_self`. |
| `409`  | `brand_exists`         | The organisation already has an own brand on this domain, compared after normalisation. An archived own brand still holds its domain. | `existing_id`: the brand that holds the domain. Left out in the rare case where that brand can no longer be read.                                                                    |
| `409`  | `external_ref_exists`  | A brand in the organisation already carries this `external_ref`.                                                                      | `existing_id`: the brand that holds it. Left out in the rare case where that brand can no longer be read.                                                                            |
| `422`  | `country_cap_exceeded` | `markets`, added to the markets the organisation's active prompts already hold, would exceed its country cap.                         | `cap`, `attempted`, `currently_used` and `requested`.                                                                                                                                |
| `422`  | `brand_cap_exceeded`   | The organisation is at its plan's cap on own brands.                                                                                  | `tier`, `cap` and `attempted`. Each one is optional.                                                                                                                                 |

Each of these answers is stored under the `Idempotency-Key` you sent. Once you have fixed the cause, send the request again under a **new** key: the old key replays the refusal. See [Idempotent requests](/api/idempotency).

On `brand_exists` and `external_ref_exists`, read the brand named by `existing_id` with `GET /orgs/{orgId}/brands/{brandId}` and carry on from there instead of creating it. That is also what happens when you repeat a provision call that had already succeeded without sending the same `Idempotency-Key`. `existing_id` is present except in a rare race between two requests. If it is missing, find the brand in `GET /orgs/{orgId}/brands` by its `domain` or its `external_ref`. That list leaves out archived brands.

`country_cap_exceeded` names both sides of the sum, so you can see which market does not fit. `attempted` is the number of markets the organisation would hold.

```json theme={null}
{
  "error": {
    "code": "country_cap_exceeded",
    "message": "Countries cap reached: the organisation may hold 4 market(s) across its active prompts; these markets would make it 5.",
    "details": {
      "cap": 4,
      "attempted": 5,
      "currently_used": ["DE", "GB", "IE", "US"],
      "requested": ["GB", "FR"]
    }
  },
  "request_id": "0b9d2c4e-6f1a-4b3c-8d5e-7a9f0c1e2d3b"
}
```

The numbers in this example are illustrative. The organisation's current cap is whatever `details.cap` reports, and `usage` on `GET /orgs/{orgId}` shows its live usage and caps at any time. [Plans & limits](/billing/plans-limits) explains what each cap counts. For the errors every endpoint shares, see [Errors](/api/errors).

## Partial success

<Warning>
  **A `422 competitor_cap_exceeded` from this endpoint means the brand was created.** Never send the provision call again after it. Take `details.brand_id` and add the remaining competitors one by one with `POST /orgs/{orgId}/brands/{brandId}/competitors`.
</Warning>

The brand is created first. The competitors are then linked one by one, and each link is its own operation. When the plan's per-brand competitor cap refuses one of them:

* linking stops, because every later competitor would hit the same cap;
* the brand stays, and so does every competitor linked before the refusal;
* the refused competitor leaves nothing behind;
* the response is `422 competitor_cap_exceeded`, and `details` tells you exactly what was applied.

`details` has three fields:

<ResponseField name="details.brand_id" type="string">
  The id of the brand that was created.
</ResponseField>

<ResponseField name="details.brand" type="object">
  The created property, in the same shape as `data` in a `201`. Its `competitors` list holds the ones that were linked.
</ResponseField>

<ResponseField name="details.competitors" type="object[]">
  One entry per competitor you sent, in request order, after duplicate domains were removed. `name` is as you sent it. `domain` is as you sent it, normalised — match your own records on `domain`. `status` is one of:

  * `applied` — linked. The entry carries `competitor_id` and `reused`.
  * `failed` — refused. The entry carries `error`, with a `code` and a `message`.
  * `not_attempted` — an earlier competitor hit the cap, so this one was never tried.
</ResponseField>

In this example the request named three competitors, and the cap refused the second.

```json theme={null}
{
  "error": {
    "code": "competitor_cap_exceeded",
    "message": "The property was created but not every competitor could be linked; `details.competitors` says which. Retry the failed ones through POST …/brands/{id}/competitors once there is headroom.",
    "details": {
      "brand_id": "c2a4e8f0-6b1d-4c3a-8e5f-9d7b2a1c4e60",
      "brand": {
        "id": "c2a4e8f0-6b1d-4c3a-8e5f-9d7b2a1c4e60",
        "name": "Hotel Aurora",
        "domain": "hotel-aurora.example",
        "is_own": true,
        "external_ref": "PROP-01342",
        "geography": "GB",
        "sunset_at": null,
        "created_at": "2026-09-21T09:14:07Z",
        "updated_at": "2026-09-21T09:14:07Z",
        "competitors": [
          {
            "id": "a41d9c6e-0b2f-4f3a-8c75-9e1d2b3a4c5d",
            "name": "Grand Meridian",
            "domain": "grand-meridian.example"
          }
        ],
        "last_scan_at": null,
        "last_scan_changed_at": "2026-09-21T09:14:08Z",
        "next_scheduled_scan": null,
        "markets": {
          "accepted": ["GB"],
          "currently_used": [],
          "cap": 4
        }
      },
      "competitors": [
        {
          "name": "Grand Meridian",
          "domain": "grand-meridian.example",
          "status": "applied",
          "competitor_id": "a41d9c6e-0b2f-4f3a-8c75-9e1d2b3a4c5d",
          "reused": true
        },
        {
          "name": "Harbour Court",
          "domain": "harbour-court.example",
          "status": "failed",
          "error": {
            "code": "competitor_cap_exceeded",
            "message": "Competitors per brand cap reached."
          }
        },
        {
          "name": "Villa Serena",
          "domain": "villa-serena.example",
          "status": "not_attempted"
        }
      ]
    }
  },
  "request_id": "4c7e1a9b-2d5f-4e8a-b3c6-9f0a1b2c3d4e"
}
```

The numbers in this example are illustrative. A `message` is written for people and can carry more detail than this one, so do not parse it. The organisation's current competitor cap is `usage.competitors.cap` on `GET /orgs/{orgId}`.

### The same shape under a `409`

A competitor can also be refused for a reason other than the cap — for example `conflict`, when another request linked the same competitor to the new brand at the same moment. The API records that competitor as `failed`, carries on with the rest, and answers `409` with the same `details`. `error.code` is the code of the first competitor that failed. There are no `not_attempted` entries in this case, because linking did not stop.

So the rule that covers every case is short: **if `error.details.brand_id` is present, the brand exists.** The samples above branch on that field rather than on a list of codes.

### Add the remaining competitors

For each entry whose `status` is not `applied`, link the competitor on its own. After a cap refusal you first need room under the cap: stop tracking another competitor with `DELETE /orgs/{orgId}/brands/{brandId}/competitors/{competitorId}`, or move the organisation to a plan with a higher cap. The cap is `usage.competitors.cap` on `GET /orgs/{orgId}`.

<CodeGroup>
  ```bash cURL theme={null}
  ORG_ID="7b1e6c1a-3f52-4d0e-9a57-2c8f1e0b4d11"
  BRAND_ID="c2a4e8f0-6b1d-4c3a-8e5f-9d7b2a1c4e60"

  curl -sS -i -X POST "https://api.surfais.com/v1/orgs/$ORG_ID/brands/$BRAND_ID/competitors" \
    -H "Authorization: Bearer $SURFAIS_API_KEY" \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: $(uuidgen)" \
    -d '{ "name": "Villa Serena", "domain": "villa-serena.example" }'
  ```

  ```python Python theme={null}
  import os
  import uuid

  import requests

  BASE_URL = "https://api.surfais.com/v1"
  ORG_ID = "7b1e6c1a-3f52-4d0e-9a57-2c8f1e0b4d11"
  BRAND_ID = "c2a4e8f0-6b1d-4c3a-8e5f-9d7b2a1c4e60"

  still_to_link = [
      {"name": "Villa Serena", "domain": "villa-serena.example"},
      {"name": "The Linden", "domain": "the-linden.example"},
  ]

  for competitor in still_to_link:
      response = requests.post(
          f"{BASE_URL}/orgs/{ORG_ID}/brands/{BRAND_ID}/competitors",
          headers={
              "Authorization": f"Bearer {os.environ['SURFAIS_API_KEY']}",
              "Idempotency-Key": str(uuid.uuid4()),
          },
          json=competitor,
          timeout=30,
      )
      body = response.json()

      if response.status_code == 201:
          print("linked:", body["data"]["domain"], "reused" if body["data"]["reused"] else "new")
      elif body["error"]["code"] == "conflict":
          print("already linked:", competitor["domain"])
      elif body["error"]["code"] == "competitor_cap_exceeded":
          # Nothing was linked. Every later competitor would be refused too.
          print("cap reached at:", competitor["domain"])
          break
      else:
          error = body["error"]
          raise SystemExit(f"{response.status_code} {error['code']} (request {body['request_id']})")
  ```

  ```javascript Node.js theme={null}
  // link-competitors.mjs — run with Node 18 or later: node link-competitors.mjs
  import { randomUUID } from "node:crypto";

  const BASE_URL = "https://api.surfais.com/v1";
  const ORG_ID = "7b1e6c1a-3f52-4d0e-9a57-2c8f1e0b4d11";
  const BRAND_ID = "c2a4e8f0-6b1d-4c3a-8e5f-9d7b2a1c4e60";

  const stillToLink = [
    { name: "Villa Serena", domain: "villa-serena.example" },
    { name: "The Linden", domain: "the-linden.example" },
  ];

  for (const competitor of stillToLink) {
    const response = await fetch(
      `${BASE_URL}/orgs/${ORG_ID}/brands/${BRAND_ID}/competitors`,
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${process.env.SURFAIS_API_KEY}`,
          "Content-Type": "application/json",
          "Idempotency-Key": randomUUID(),
        },
        body: JSON.stringify(competitor),
      },
    );
    const body = await response.json();

    if (response.status === 201) {
      console.log("linked:", body.data.domain, body.data.reused ? "reused" : "new");
    } else if (body.error.code === "conflict") {
      console.log("already linked:", competitor.domain);
    } else if (body.error.code === "competitor_cap_exceeded") {
      // Nothing was linked. Every later competitor would be refused too.
      console.log("cap reached at:", competitor.domain);
      break;
    } else {
      throw new Error(`${response.status} ${body.error.code} (request ${body.request_id})`);
    }
  }
  ```
</CodeGroup>

A `201` returns the competitor that was linked: `id`, `name`, `domain` and `reused`. A `409 conflict` means the competitor is already tracked for this brand, and `details.competitor_id` names it. A `422 competitor_cap_exceeded` links nothing.

<Note>
  A `500 internal_error` or a `503 store_unavailable` can also leave the brand behind, when the failure came after the brand was created. Send the call again. If the answer is `409 brand_exists`, the first attempt created the brand: read it, compare its `competitors` with your list, and add the ones that are missing.
</Note>

## How competitors are reused

A competitor belongs to the organisation and can be tracked by several of its own brands.

* If the organisation already has a competitor on the same domain, compared after normalisation, the API links that one instead of creating a second. The entry reports `reused: true`, and the competitor **keeps its stored name** — the `name` you sent is not applied.
* Two entries with the same domain in one request collapse into the first.
* `reused` appears in `details.competitors` of a partial response and in the `201` of `POST /orgs/{orgId}/brands/{brandId}/competitors`. The `201` of the provision call lists the competitors as they are stored, so a reused competitor shows its existing name there.
* `DELETE /orgs/{orgId}/brands/{brandId}/competitors/{competitorId}` removes the link between the two brands only. The competitor stays in the organisation and stays readable by id.

See [Adding competitors](/setup/competitors) for how tracked competitors feed share of voice.

## Next steps

A new property has no prompts, so nothing is scanned yet.

<CardGroup cols={2}>
  <Card title="Sync a brand's prompts" icon="list-check" href="/api/guides/sync-prompts">
    Send the prompt set for the new property, with the countries each prompt runs in.
  </Card>

  <Card title="Run a scan" icon="radar" href="/api/guides/run-a-scan">
    Request the property's first scan once it has at least one active prompt.
  </Card>
</CardGroup>

### Partners: set your id on the organisation

A partner key can store the partner's own identifier for a client organisation on its link to that organisation. It comes back as `external_ref` on `GET /orgs` and `GET /orgs/{orgId}`, and as `org_external_ref` in webhook events.

```bash cURL theme={null}
ORG_ID="7b1e6c1a-3f52-4d0e-9a57-2c8f1e0b4d11"

curl -sS -X PATCH "https://api.surfais.com/v1/orgs/$ORG_ID/link" \
  -H "Authorization: Bearer $SURFAIS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "external_ref": "GROUP-17" }'
```

```json theme={null}
{
  "data": {
    "org_id": "7b1e6c1a-3f52-4d0e-9a57-2c8f1e0b4d11",
    "scope": "read_write",
    "status": "active",
    "external_ref": "GROUP-17",
    "granted_at": "2026-09-01T10:00:00Z"
  }
}
```

Send `null` to clear the value. This endpoint is for partner keys only, and it follows the same write rule as every other write: the key needs the `write` scope and the link must be `read_write`. The write rule is checked first, so a read-only organisation key gets `403 write_scope_required`, and an organisation key that can write gets `403 partner_only`.
