# upsert_spots

Create or update up to 25 spots by your own ref.

`POST /v1/guides/{productId}/spots/batch` · MCP tool `upsert_spots` · scope `write` · beta · since 2026-09-30

Bulk and idempotent: each item is a create_spot body plus a required ref (your stable key). A spot of this guide with that ref is updated with the fields you send; any other ref creates a spot. One transaction per call, so one bad item rejects the batch and nothing half-lands; the error names the ref. Returns [{ ref, spotId, spotKey, created }] and any warnings, with status 201 even when every item was an update. Re-running the same batch never duplicates spots.

## Path parameters

- `productId` (string, required): Id of the guide (a product of type map). From list_guides or create_guide.

## Body parameters

- `spots` (array of objects, required): Up to 25 items: every create_spot field plus ref (required).
  - `title` (string, required): Place name as locals know it, e.g. Oeschinensee or Fushimi Inari-taisha. Cannot be empty.
  - `kicker` (string, optional): Short eyebrow above the title, e.g. Patagonia, Bernese Oberland or Glacier lake. Empty string clears.
  - `lat` (number, optional): Latitude in decimal degrees (-90 to 90). From the creator's material or a cited source.
  - `lon` (number, optional): Longitude in decimal degrees (-180 to 180).
  - `mapsUrl` (string, optional): The creator's own map link: https on Google Maps, Apple Maps, Swisstopo, OpenStreetMap or SchweizMobil, or a geo: URI. Empty string clears.
  - `shortDescription` (string, optional): One or two sentences for the card. Empty string clears.
  - `longDescription` (string, optional): The full write-up in the creator's voice. Empty string clears.
  - `color` (string, optional): Optional pin color hex, e.g. #0071e3. Empty string clears.
  - `cardOrientation` (string, optional): portrait or landscape (photo framing in the viewer); a spot without one reads as landscape. One of `portrait`, `landscape`.
  - `customValues` (map of values, optional): Object keyed by property key (list_properties): text for shortLabel and longSection, a number for number, epoch ms for date, one option id for singleSelect, an array of option ids for checkbox (ids, never labels). Every required property needs a value; keys without a live property are kept unchecked. Sent for an existing spot it replaces the stored object; null for a key clears that value.
  - `arrival` (Arrival or null, optional): How to get there, full replacement, null clears: { byCar?: { directions, parking, walkToSpotMin, walkRoundTrip? (the minutes are there and back), none? (no car access), note? }, byTrainBus?: { route, walkFromStopMin, walkRoundTrip?, none? (no public transport, which reads differently from an empty field), note? }, byBoat?, byAir?: { airports? (IATA or ICAO codes), route?, minutes?, operatorUrl?, luggageKg?, none?, note? }, onFoot?: { route?, minutes?, roundTrip?, none?, note? }, byBike?: { same }, bestTime?, operating?, approaches?: [{ label (Argentine side), country? (AR), legs?: [{ mode: car|bus|train|tram|metro|cable_car|funicular|boat|ferry|plane|helicopter|shuttle|jeep|walk|bike|taxi|other, from?, to?, minutes?, roundTrip?, price?, payment?, note? }], hours?, entry?, note?, lat?, lon? }] (at most 6, for spots with several ways in or chains of legs), rules?: [{ text, kind?: reservation|permit|guide|registration|no_independent_travel|vehicle|other, months? [1-12] (shown only then), appliesTo?: car|all, bookingOpens?, sourceUrl? }] }.
    Fields of Arrival: https://developers.sceniq.earth/fields/arrival.md
  - `mapLinks` (array of MapLink, optional): Extra map links [{ label, url }] (https or an allowlisted map link), drawn as map pills. [] clears. For official, ticket or status pages use links instead.
    Fields of MapLink: https://developers.sceniq.earth/fields/map-link.md
  - `imagesEnabled` (boolean, optional): false hides the photo section from buyers; photos stay stored.
  - `customPropsEnabled` (boolean, optional): false hides custom properties from buyers; values stay stored.
  - `tripFacts` (TripFacts or null, optional): The planning stats bar, null clears: { elevationM? (meters; 0 is a stated sea level, absent is unknown), elevationRefersTo?: viewpoint|summit|ground|water|trailhead, elevationSource?, busyness? (1 quiet to 5 packed, shown as Crowdedness), bestSeason? }.
    Fields of TripFacts: https://developers.sceniq.earth/fields/trip-facts.md
  - `accessMode` (string or null, optional): How visitors reach the spot, shown as Accessibility: drive_up, short_walk, hike, multi_day, boat, cable_car, train, flight, tour_only, aerial_only. null clears. Without it the cell shows the easiest required route's grade, or Drive-up only when arrival.byCar describes a drive. One of `drive_up`, `short_walk`, `hike`, `multi_day`, `boat`, `cable_car`, `train`, `flight`, `tour_only`, `aerial_only`.
  - `pinLabel` (string, optional): What the main pin marks for a spot that is an area or a line, shown as Pinned: <label> (Ti Top viewpoint). Empty string clears.
  - `pins` (array of SpotPin, optional): Extra points, full replacement ([] clears), at most 12: [{ kind: viewpoint|entrance|gate|trailhead|parking|stop|pier|summit|other, label, lat, lon, months? [1-12] (only reachable then), note? }]. Each gets its own map links and a place on the web map.
    Fields of SpotPin: https://developers.sceniq.earth/fields/spot-pin.md
  - `links` (array of GuideLink, optional): Links with a purpose, full replacement ([] clears), at most 12: [{ kind: official|website|tickets|booking|status|timetable|tide|listing|social|authority|app|other, label?, url? (http or https; http is accepted with a warning), value? (a channel that is not a URL, e.g. WeChat mini program: Li River boats), note? }]. Each needs a url or a value.
    Fields of GuideLink: https://developers.sceniq.earth/fields/guide-link.md
  - `status` (StatusNotice or null, optional): Closure or works notice, null clears: { state: open|partly_closed|closed|reopening, note?, since?, until? (the day the state is expected to end; buyers stop seeing the notice after it), sourceUrl?, checkedOn? }. reopening with until means reopens on that day.
    Fields of StatusNotice: https://developers.sceniq.earth/fields/status-notice.md
  - `fees` (array of Fee, optional): Fees and tickets, full replacement ([] clears), at most 30: [{ label, amount? (major units, e.g. 40 or 12.5; needs currency), currency? (EUR), free?, seeOfficial? (a price left to the official page on purpose), per?: person|vehicle|night|group|entry|day|hour, audience?: all|adult|child|foreign|domestic|resident|student|senior, paidWhere?: online|on_site|in_tour, payment?: cash_only|card_only|cash_or_card, note?, validFrom?, validUntil?, sourceUrl?, checkedOn? }]. Buyers see the amount with an as-of date; readiness flags rows past validUntil or unchecked for a year.
    Fields of Fee: https://developers.sceniq.earth/fields/fee.md
  - `schedule` (Schedule or null, optional): Structured opening hours, null clears; the free-text hours stay the fallback: { hours?: [{ from? (MM-DD), to? (MM-DD, may wrap over the new year), weekdays? [1-7], closed?, open? (HH:MM or sunrise|sunset), close? (HH:MM or sunrise|sunset), openOffsetMin?, closeOffsetMin? (minutes around a sun anchor, -60 = an hour before), lastEntry? (HH:MM), leaveBy? (HH:MM), note? }], specialDays?: [{ date? or rule? (first Sunday of the month), closed?, open?, close?, note? }], slots?: { first, last, everyMin, cap?, note? }, validFrom?, validUntil?, sourceUrl?, checkedOn?, note? }. The last matching band wins, so list the year-round band first and exceptions after it. The apps show today's hours in the spot's time zone.
    Fields of Schedule: https://developers.sceniq.earth/fields/schedule.md
  - `features` (array of SpotFeature, optional): Parts of the site with their own rules (a monastery's closing day, a ticketed skyway), full replacement, at most 12: [{ name, note?, hours?, entry?, season?, fees? (as fees), status? (as status), url? }].
    Fields of SpotFeature: https://developers.sceniq.earth/fields/spot-feature.md
  - `restrictions` (array of Restriction, optional): Rules on site, full replacement, at most 20: [{ kind: no_photo|wide_only|no_drone|no_entry|no_stopping|no_parking|other, label, note?, lat?, lon?, radiusM?, line? ([[lon, lat], ...] for a stretch of road), sourceUrl? }].
    Fields of Restriction: https://developers.sceniq.earth/fields/restriction.md
  - `events` (array of SpotEvent, optional): Recurring or one-off events worth timing a visit for, full replacement, at most 12: [{ name, when (in words: every night at 19:45 and 20:45), dates? [YYYY-MM-DD] (the apps show the next one), months?, url?, free?, note? }].
    Fields of SpotEvent: https://developers.sceniq.earth/fields/spot-event.md
  - `food` (FoodNote or null, optional): Why the spot lists no food places, null clears: { status: included (meals come with the lodge or boat) | none_nearby | bring_your_own, note? }.
    Fields of FoodNote: https://developers.sceniq.earth/fields/food-note.md
  - `areaId` (string, optional): The area (park, island, region) the spot sits in, from set_area; its rules, fees and links show on the spot. Empty string clears.
  - `timeZone` (string, optional): IANA zone (America/Phoenix). Derived from the pin on every write; set it only when the lookup is wrong near a border. Empty string hands it back to the pin.
  - `customNotes` (map of strings, optional): One short note per custom property value, keyed by property key, shown next to the value (walls 40 EUR, morning from viewpoint 7). Full replacement ({} clears).
  - `sources` (array of SourceRef, optional): Research evidence for reviewers, never shown to buyers; full replacement, at most 40: [{ url? or title?, kind?: page|api|archive|document|other, capturedOn?, note?, supports? (which facts it backs, e.g. fees, arrival.operating, lat/lon), conflict? (true when it disagrees with another source on those facts) }].
    Fields of SourceRef: https://developers.sceniq.earth/fields/source-ref.md
  - `reviewNotes` (string, optional): Notes for the creator's review (why a value was chosen, what could not be verified). Never shown to buyers; get_guide_readiness lists them. Empty string clears.
  - `factsCheckedOn` (string, optional): The day the facts were last checked against their sources (YYYY-MM-DD). Empty string clears.
  - `reviewBy` (string, optional): The day the facts need a new check (YYYY-MM-DD), e.g. when a season, fare or timetable runs out. get_guide_readiness and get_publish_status warn once it has passed. Empty string clears.
  - `siteChange` (SiteChange or null, optional): The day something at the spot changed (a bridge replaced, boats banned), null clears: { on (YYYY-MM-DD), note? }. Readiness flags photos captured before it.
    Fields of SiteChange: https://developers.sceniq.earth/fields/site-change.md
  - `ref` (string, required): Required: your own stable key for the spot, 1 to 80 characters (letters, digits, dot, underscore, colon or hyphen, a letter or digit first), once per batch. A spot of this guide with that ref is updated with the fields sent; any other ref creates a spot.

## Returns

201.

- `productId` (string, required): The guide's id.
- `created` (number, required): Spots created.
- `updated` (number, required): Spots updated.
- `results` (array of objects, required): One result per item, in order.
  - `ref` (string, required): Your ref.
  - `spotId` (string, required): The spot's id.
  - `spotKey` (string, required): The spot's key.
  - `created` (boolean, required): true when this call created the spot.
- `warnings` (array of strings, optional): Notes about things the write accepted but you should look at (a plain http link). Present only when there are any.

## Examples

### First run: three spots created by your own ref

```bash
curl -X POST "https://sceniq.earth/api/v1/guides/j5738kd1q9x2m7v4c6b8n0p3r5t1w9y2/spots/batch" \
  -H "Authorization: Bearer $SCENIQ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "spots": [
    {
      "ref": "yose-tunnel-view",
      "title": "Tunnel View",
      "kicker": "Yosemite Valley",
      "lat": 37.7156,
      "lon": -119.677,
      "mapsUrl": "https://www.google.com/maps/search/?api=1&query=37.7156,-119.677",
      "shortDescription": "El Capitan, Half Dome and Bridalveil Fall in one frame, at the east end of the Wawona Tunnel.",
      "accessMode": "drive_up",
      "customValues": {
        "best_time": "sunset",
        "crowds": "busy"
      }
    },
    {
      "ref": "yose-glacier-point",
      "title": "Glacier Point",
      "kicker": "Yosemite, above the valley",
      "lat": 37.7307,
      "lon": -119.5741,
      "shortDescription": "Half Dome, Vernal Fall and Nevada Fall from nearly 1,000 meters above the valley floor.",
      "accessMode": "drive_up",
      "customValues": {
        "best_time": "sunset",
        "crowds": "packed"
      }
    },
    {
      "ref": "yose-taft-point",
      "title": "Taft Point",
      "kicker": "Yosemite, Glacier Point Road",
      "lat": 37.7125,
      "lon": -119.6049,
      "shortDescription": "Fissures in the granite and a sheer drop to the valley, with El Capitan across the gap.",
      "accessMode": "hike",
      "customValues": {
        "best_time": "sunset",
        "crowds": "busy"
      }
    }
  ]
}'
```

MCP `tools/call`:

```json
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "upsert_spots",
    "arguments": {
      "productId": "j5738kd1q9x2m7v4c6b8n0p3r5t1w9y2",
      "spots": [
        {
          "ref": "yose-tunnel-view",
          "title": "Tunnel View",
          "kicker": "Yosemite Valley",
          "lat": 37.7156,
          "lon": -119.677,
          "mapsUrl": "https://www.google.com/maps/search/?api=1&query=37.7156,-119.677",
          "shortDescription": "El Capitan, Half Dome and Bridalveil Fall in one frame, at the east end of the Wawona Tunnel.",
          "accessMode": "drive_up",
          "customValues": {
            "best_time": "sunset",
            "crowds": "busy"
          }
        },
        {
          "ref": "yose-glacier-point",
          "title": "Glacier Point",
          "kicker": "Yosemite, above the valley",
          "lat": 37.7307,
          "lon": -119.5741,
          "shortDescription": "Half Dome, Vernal Fall and Nevada Fall from nearly 1,000 meters above the valley floor.",
          "accessMode": "drive_up",
          "customValues": {
            "best_time": "sunset",
            "crowds": "packed"
          }
        },
        {
          "ref": "yose-taft-point",
          "title": "Taft Point",
          "kicker": "Yosemite, Glacier Point Road",
          "lat": 37.7125,
          "lon": -119.6049,
          "shortDescription": "Fissures in the granite and a sheer drop to the valley, with El Capitan across the gap.",
          "accessMode": "hike",
          "customValues": {
            "best_time": "sunset",
            "crowds": "busy"
          }
        }
      ]
    }
  }
}
```

Response 201:

```json
{
  "productId": "j5738kd1q9x2m7v4c6b8n0p3r5t1w9y2",
  "created": 3,
  "updated": 0,
  "results": [
    {
      "ref": "yose-tunnel-view",
      "spotId": "k17bc3xz8h6f4d2s0q9w7e5r3t1y2v4j",
      "spotKey": "01M2Z1G0Y0R2K5P8S1V4X7Z0C3",
      "created": true
    },
    {
      "ref": "yose-glacier-point",
      "spotId": "k1736b344br1c7m8qk8ks4sfndxyyftj",
      "spotKey": "01M3H2AWH0F9PB64V7QB8QS0MC",
      "created": true
    },
    {
      "ref": "yose-taft-point",
      "spotId": "k17hdgc1c8j423kgq30t9x5hyv1tv8yb",
      "spotKey": "01M3H2AXG8GB8YNF4FCEFARFN5",
      "created": true
    }
  ]
}
```

### Run again with changes: known refs update, a new ref creates, nothing duplicates

```bash
curl -X POST "https://sceniq.earth/api/v1/guides/j5738kd1q9x2m7v4c6b8n0p3r5t1w9y2/spots/batch" \
  -H "Authorization: Bearer $SCENIQ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "spots": [
    {
      "ref": "yose-tunnel-view",
      "title": "Tunnel View",
      "longDescription": "The view opened with the Wawona Tunnel in 1933: El Capitan on the left, Half Dome at the back, Bridalveil Fall on the right. The lots fill for sunset from spring to autumn; in winter, come as a storm clears.",
      "sources": [
        {
          "url": "http://www.yosemite.ca.us/library/wawona_tunnel.html",
          "title": "Wawona Tunnel history",
          "kind": "document",
          "supports": [
            "longDescription"
          ]
        }
      ]
    },
    {
      "ref": "yose-glacier-point",
      "title": "Glacier Point",
      "arrival": {
        "byCar": {
          "directions": "Glacier Point Road from Chinquapin, 26 km to the end of the road.",
          "parking": "Large lot at the end of the road; full by late afternoon in summer.",
          "walkToSpotMin": 5
        },
        "rules": [
          {
            "text": "Glacier Point Road is closed to cars in winter, usually from November to late May.",
            "kind": "vehicle",
            "months": [
              11,
              12,
              1,
              2,
              3,
              4,
              5
            ],
            "appliesTo": "car",
            "sourceUrl": "https://www.nps.gov/yose/planyourvisit/conditions.htm"
          }
        ]
      }
    },
    {
      "ref": "yose-sentinel-dome",
      "title": "Sentinel Dome",
      "kicker": "Yosemite, Glacier Point Road",
      "lat": 37.7231,
      "lon": -119.5845,
      "shortDescription": "A bare granite dome with a view all the way round, from El Capitan to Half Dome.",
      "accessMode": "hike"
    }
  ]
}'
```

MCP `tools/call`:

```json
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "upsert_spots",
    "arguments": {
      "productId": "j5738kd1q9x2m7v4c6b8n0p3r5t1w9y2",
      "spots": [
        {
          "ref": "yose-tunnel-view",
          "title": "Tunnel View",
          "longDescription": "The view opened with the Wawona Tunnel in 1933: El Capitan on the left, Half Dome at the back, Bridalveil Fall on the right. The lots fill for sunset from spring to autumn; in winter, come as a storm clears.",
          "sources": [
            {
              "url": "http://www.yosemite.ca.us/library/wawona_tunnel.html",
              "title": "Wawona Tunnel history",
              "kind": "document",
              "supports": [
                "longDescription"
              ]
            }
          ]
        },
        {
          "ref": "yose-glacier-point",
          "title": "Glacier Point",
          "arrival": {
            "byCar": {
              "directions": "Glacier Point Road from Chinquapin, 26 km to the end of the road.",
              "parking": "Large lot at the end of the road; full by late afternoon in summer.",
              "walkToSpotMin": 5
            },
            "rules": [
              {
                "text": "Glacier Point Road is closed to cars in winter, usually from November to late May.",
                "kind": "vehicle",
                "months": [
                  11,
                  12,
                  1,
                  2,
                  3,
                  4,
                  5
                ],
                "appliesTo": "car",
                "sourceUrl": "https://www.nps.gov/yose/planyourvisit/conditions.htm"
              }
            ]
          }
        },
        {
          "ref": "yose-sentinel-dome",
          "title": "Sentinel Dome",
          "kicker": "Yosemite, Glacier Point Road",
          "lat": 37.7231,
          "lon": -119.5845,
          "shortDescription": "A bare granite dome with a view all the way round, from El Capitan to Half Dome.",
          "accessMode": "hike"
        }
      ]
    }
  }
}
```

Response 201:

```json
{
  "productId": "j5738kd1q9x2m7v4c6b8n0p3r5t1w9y2",
  "created": 1,
  "updated": 2,
  "results": [
    {
      "ref": "yose-tunnel-view",
      "spotId": "k17bc3xz8h6f4d2s0q9w7e5r3t1y2v4j",
      "spotKey": "01M2Z1G0Y0R2K5P8S1V4X7Z0C3",
      "created": false
    },
    {
      "ref": "yose-glacier-point",
      "spotId": "k1736b344br1c7m8qk8ks4sfndxyyftj",
      "spotKey": "01M3H2AWH0F9PB64V7QB8QS0MC",
      "created": false
    },
    {
      "ref": "yose-sentinel-dome",
      "spotId": "k17hh93896ctjmrxq55mxp7qt176q6f6",
      "spotKey": "01M3KMNRY0V8ZPTG4HCY75DZFB",
      "created": true
    }
  ],
  "warnings": [
    "yose-tunnel-view: sources 1 url uses http, not https: http://www.yosemite.ca.us/library/wawona_tunnel.html"
  ]
}
```

## Errors

- `invalid_argument` (400): A required field is missing, a field is unknown, or a value does not match its type (the Convex validator's path and value are in the message).
- `invalid_request` (400): A writer refused a value: a map link on a host that is not allowed, a title that is too long, a date that does not exist, too many items in a list. The message is the writer's own sentence.
- `unauthenticated` (401): The request carries no key. Send it as Authorization: Bearer sk_sceniq_... (or X-Api-Key).
- `invalid_api_key` (401): The key is unknown, revoked or expired.
- `api_scope_required` (403): A read-only key called a write operation, or a key without the publish option called publish_changes.
- `forbidden` (403): The key belongs to a creator account that is no longer active.
- `not_found` (404): The id does not exist or belongs to another creator. The two answers are identical on purpose, so ids never reveal what exists.
- `payload_too_large` (413): A JSON body over 2 MB, an image over 6 MB, a batch upload over 19 MB, or an MCP message over 2 MB.
- `rate_limited` (429): Over a limit: requests per key per minute, per creator per hour, uploads per key per minute, or check_links per guide per hour.
- `internal_error` (500): An unexpected failure. The message is hidden on purpose.

Reference: https://developers.sceniq.earth/reference/upsert_spots
