---
title: "Propose or make many changes at once"
method: POST
path: "/api/entity/batch"
tags: ["Proposing changes"]
---

# Propose or make many changes at once

`POST /api/entity/batch`

## Request

- Base URL: `https://verifiedhandles.org`
- URL: `https://verifiedhandles.org/api/entity/batch`
- Auth: HTTP bearer

Up to 25 edits in one request, each to a different entry: all proposals (`mode: "propose"`, as `POST /api/entity/{vhid}/proposals`) or all live edits (`mode: "live"`, as the editor’s Save makes them). Each edit is judged and made exactly as the single route makes it: whether this account may (entry by entry: a live edit needs `edit_live`, or `edit_locked` on an entry locked to admins), the conflict check against `base_revision_id`, one revision and one audit row per entry. A batch grants nothing a single call would refuse. One thing it refuses that a single call takes: naming a Wikipedia page for the credit (`credit_in_batch`), which the server reads from Wikipedia; send that one alone.

**One at a time** (the default): each edit in its own transaction, in order. One edit refused leaves the others made; the answer is 200 with each edit’s outcome, in request order, with the code and status the single route would have answered.

**`atomic: true`**: all or nothing. Every entry is locked first, in VHID order, and any refused edit undoes the whole batch: 422 `batch_failed`, naming every refused edit and why; the rest are `not_applied`, and nothing is written.

**Replaying** a batch whose answer was lost is safe: an edit already made answers `conflict`, with `revisions_since_base: 1` and `all_since_base_by_you: true`; a proposal already filed answers its own `submission_id`. Every audit row of a batch carries its `batch_id`.

**Rate limit.** Every edit counts as one change against the IP address, account and key, so 25 edits cost what they would cost sent one by one; the request is refused (429) before any edit runs when they do not fit. Takes an optional `Idempotency-Key`, so a request whose answer was lost can be sent again safely: the retry gets the first answer back.

## Headers

- `Idempotency-Key` string

## Request body

- object
  - `mode` 'live' | 'propose', required: Live edits or proposals: one kind per batch.
  - `atomic` boolean: All or nothing. Default false: each edit on its own.
  - `summary` string: Each live edit’s why, unless it gives its own. A proposal takes none.
  - `edits` object[], required
    - `title` string
    - `subtitle` string, nullable
    - `description` string, nullable: HTML, sanitised on the way in by the same allowlist as every description. At most 64 KiB.
    - `description_source` unknown
    - `wikipedia_credit` object: Credit the description “from Wikipedia”. The server reads the page itself (redirects followed, so a draft since moved is its article), and refuses it unless at least half of the description’s three-word phrases are the page’s lead (`409 credit_text_differs`, with `page` and `share`). Not with `description_source: null`. Errors: `400 bad_wikipedia_title` / `not_english_wikipedia`; `409 wikipedia_page_missing`, `wikipedia_namespace` (not an article or a draft), `wikipedia_section`, `wikipedia_no_lead`, `no_description`, `draft_has_article` (the entry’s item already has an English article, named in `article`: credit that); `502 wikipedia_unreachable`.
      - `page` string, required: An English Wikipedia article or draft, by title (`Draft:Dominic McLaughlin`) or address (`https://en.wikipedia.org/wiki/…`).
      - `let_wikidata_manage` boolean: For a draft only: `false` keeps the description site-authored. By default a draft’s credit hands the description to the Wikidata sync, which leaves it alone until the draft is an article linked from the entry’s Wikidata item.
    - `short` string, nullable: The short name (`/<short>`); `null` removes it.
    - `image_id` string, nullable: The lead image, by media id; `null` removes it.
    - `gallery` string[]: The whole gallery after the lead, in order, by media id: it replaces the one there.
    - `image_links` object: The whole map of links on the entry’s images, media id → absolute http(s) address. Needs the `link_images` capability.
    - `custom_handle_order` boolean: Keep `handles` in the order given, rather than the site’s order.
    - `handles` object[]: The whole list of handles, in order: it replaces the one there. Not with `add_handles` / `remove_handles`.
      - `platform_id` string, required
      - `value` string, required
      - `display` string, nullable: A label to show instead of the value.
    - `add_handles` object[]: Handles to put on the entry, at the end of its list as it is when the change is applied (a proposal: when it is approved). One the entry has already (the same value, in any case the platform folds) changes nothing. No base revision is needed for it. Not with `handles` (400 `handles_with_ops`).
      - `platform_id` string, required
      - `value` string, required: Not blank. A website is completed to an address, as in `handles`.
      - `display` string, nullable: A label to show instead of the value.
    - `remove_handles` object[]: Handles to take off, by value — or, for an email the caller may not see, by `ref` (an address is refused: `masked_handle_needs_ref`). One the entry does not have changes nothing. A value the Wikidata sync supplied is held back from it, as when a list leaves it out. Not with `handles`.
      - `platform_id` string, required: The platform’s id.
      - `value` string: The handle, as stored or in any case the platform folds.
      - `ref` string: Instead of `value`, for an email the caller may not see: the `ref` `GET /api/editor/{vhid}` gives it.
    - `relinquish` string[]: Fields to hand back to the Wikidata sync.
    - `takeover` string[]: Fields to take from the Wikidata sync, keeping their value.
    - `handle_takeover` object[]: Handles to take from the Wikidata sync.
      - `platform_id` string, required: The platform’s id.
      - `value` string, required: The handle as stored.
    - `handle_relinquish` object[]: Handles to hand to the Wikidata sync, while Wikidata lists them.
      - `platform_id` string, required: The platform’s id.
      - `value` string, required: The handle as stored.
    - `handle_release` object[]: Values held back from the sync that it may add again.
      - `platform_id` string, required: The platform’s id.
      - `value` string, required: The handle as stored.
    - `vhid` string, required: The entry’s VHID (11 digits). Each entry at most once in a batch.
    - `base_revision_id` string: The revision the change was made against (`baseRevisionId` of `GET /api/editor/{vhid}`): needed for a live edit, unless it is handle ops alone.
    - `summary` string: A live edit’s why, shown in the history. Defaults to the batch’s `summary`.
    - `payload` EditPayload: A change to an entry, as the editor sends it. Every field is optional: send only what changes. `handles` and `gallery` are whole lists, which replace the entry’s; `add_handles` / `remove_handles` name single handles instead, applied to the list as it is then, so a change of nothing else needs no base revision and never undoes a handle saved meanwhile. A field the server does not know is ignored.
      - `title` string
      - `subtitle` string, nullable
      - `description` string, nullable: HTML, sanitised on the way in by the same allowlist as every description. At most 64 KiB.
      - `description_source` unknown
      - `wikipedia_credit` object: Credit the description “from Wikipedia”. The server reads the page itself (redirects followed, so a draft since moved is its article), and refuses it unless at least half of the description’s three-word phrases are the page’s lead (`409 credit_text_differs`, with `page` and `share`). Not with `description_source: null`. Errors: `400 bad_wikipedia_title` / `not_english_wikipedia`; `409 wikipedia_page_missing`, `wikipedia_namespace` (not an article or a draft), `wikipedia_section`, `wikipedia_no_lead`, `no_description`, `draft_has_article` (the entry’s item already has an English article, named in `article`: credit that); `502 wikipedia_unreachable`.
        - `page` string, required: An English Wikipedia article or draft, by title (`Draft:Dominic McLaughlin`) or address (`https://en.wikipedia.org/wiki/…`).
        - `let_wikidata_manage` boolean: For a draft only: `false` keeps the description site-authored. By default a draft’s credit hands the description to the Wikidata sync, which leaves it alone until the draft is an article linked from the entry’s Wikidata item.
      - `short` string, nullable: The short name (`/<short>`); `null` removes it.
      - `image_id` string, nullable: The lead image, by media id; `null` removes it.
      - `gallery` string[]: The whole gallery after the lead, in order, by media id: it replaces the one there.
      - `image_links` object: The whole map of links on the entry’s images, media id → absolute http(s) address. Needs the `link_images` capability.
      - `custom_handle_order` boolean: Keep `handles` in the order given, rather than the site’s order.
      - `handles` object[]: The whole list of handles, in order: it replaces the one there. Not with `add_handles` / `remove_handles`.
        - `platform_id` string, required
        - `value` string, required
        - `display` string, nullable: A label to show instead of the value.
      - `add_handles` object[]: Handles to put on the entry, at the end of its list as it is when the change is applied (a proposal: when it is approved). One the entry has already (the same value, in any case the platform folds) changes nothing. No base revision is needed for it. Not with `handles` (400 `handles_with_ops`).
        - `platform_id` string, required
        - `value` string, required: Not blank. A website is completed to an address, as in `handles`.
        - `display` string, nullable: A label to show instead of the value.
      - `remove_handles` object[]: Handles to take off, by value — or, for an email the caller may not see, by `ref` (an address is refused: `masked_handle_needs_ref`). One the entry does not have changes nothing. A value the Wikidata sync supplied is held back from it, as when a list leaves it out. Not with `handles`.
        - `platform_id` string, required: The platform’s id.
        - `value` string: The handle, as stored or in any case the platform folds.
        - `ref` string: Instead of `value`, for an email the caller may not see: the `ref` `GET /api/editor/{vhid}` gives it.
      - `relinquish` string[]: Fields to hand back to the Wikidata sync.
      - `takeover` string[]: Fields to take from the Wikidata sync, keeping their value.
      - `handle_takeover` object[]: Handles to take from the Wikidata sync.
        - `platform_id` string, required: The platform’s id.
        - `value` string, required: The handle as stored.
      - `handle_relinquish` object[]: Handles to hand to the Wikidata sync, while Wikidata lists them.
        - `platform_id` string, required: The platform’s id.
        - `value` string, required: The handle as stored.
      - `handle_release` object[]: Values held back from the sync that it may add again.
        - `platform_id` string, required: The platform’s id.
        - `value` string, required: The handle as stored.
    - `qid_link` object: A proposal’s Wikidata link, as at the single route.
      - `qid` string, nullable, required
      - `adopt` string[]

## Response `200`

Each edit’s outcome, in request order (one at a time, or an atomic batch that was made whole).

- object
  - `batch_id` string, required: This batch, as its audit rows name it (`detail.batch`).
  - `mode` 'live' | 'propose', required
  - `atomic` boolean, required
  - `succeeded` integer, required: How many edits were made or filed.
  - `failed` integer, required: How many were refused.
  - `results` BatchEditResult[], required
    - union: What one edit of a batch came to.
      - object
        - `vhid` string, required
        - `status` 'ok', required
        - `revision_id` string, required: The live edit’s new revision.
        - `changed` string[], required: The fields it changed.
        - `handle_ops` HandleOpsReport: What each of a change’s `add_handles` / `remove_handles` came to.
          - `added` object[], required: Put on the entry, at the end of its list.
            - `platform_id` string, required: The platform’s id.
            - `value` string, required: The handle as stored.
          - `removed` object[], required: Taken off, as the op named them.
            - `platform_id` string, required: The platform’s id.
            - `value` string: The handle, as stored or in any case the platform folds.
            - `ref` string: Instead of `value`, for an email the caller may not see: the `ref` `GET /api/editor/{vhid}` gives it.
          - `already_present` object[], required: Adds of a handle the entry has already: nothing changed.
            - `platform_id` string, required: The platform’s id.
            - `value` string, required: The handle as stored.
          - `not_present` object[], required: Removes of a handle the entry does not have: nothing changed.
            - `platform_id` string, required: The platform’s id.
            - `value` string: The handle, as stored or in any case the platform folds.
            - `ref` string: Instead of `value`, for an email the caller may not see: the `ref` `GET /api/editor/{vhid}` gives it.
      - object
        - `vhid` string, required
        - `status` 'ok', required
        - `submission_id` string, required: The proposal’s id: the one already waiting, if this one is the same.
      - object
        - `vhid` string, required
        - `status` 'error', required
        - `error` string, required: What the single route answers for this edit: the same code (`forbidden`, `conflict`, `not_found`, a 400’s, `too_large`), or `credit_in_batch`.
        - `http_status` integer, required: The status the single route answers with.
        - `latest_revision_id` string, nullable: With `conflict`: the entry’s latest revision now.
        - `revisions_since_base` integer: With `conflict`: how many revisions were saved after `base_revision_id`.
        - `all_since_base_by_you` boolean: With `conflict`: whether every one of them was saved by this account — a batch you are replaying, already applied.
        - `field` string
        - `platform_id` string
        - `reason` 'unknown' | 'disabled'
        - `message` string
        - `handle_ops` HandleOpsReport: What each of a change’s `add_handles` / `remove_handles` came to.
          - `added` object[], required: Put on the entry, at the end of its list.
            - `platform_id` string, required: The platform’s id.
            - `value` string, required: The handle as stored.
          - `removed` object[], required: Taken off, as the op named them.
            - `platform_id` string, required: The platform’s id.
            - `value` string: The handle, as stored or in any case the platform folds.
            - `ref` string: Instead of `value`, for an email the caller may not see: the `ref` `GET /api/editor/{vhid}` gives it.
          - `already_present` object[], required: Adds of a handle the entry has already: nothing changed.
            - `platform_id` string, required: The platform’s id.
            - `value` string, required: The handle as stored.
          - `not_present` object[], required: Removes of a handle the entry does not have: nothing changed.
            - `platform_id` string, required: The platform’s id.
            - `value` string: The handle, as stored or in any case the platform folds.
            - `ref` string: Instead of `value`, for an email the caller may not see: the `ref` `GET /api/editor/{vhid}` gives it.
      - object
        - `vhid` string, required
        - `status` 'not_applied', required: An atomic batch was refused: this edit would have applied, and was not.

## Other responses

- `400`: The request is not a batch: `bad_json`, `bad_mode` (not `live` or `propose`), `no_edits`, `too_many_edits` (more than 25), `bad_edit` (an edit with no `vhid`), `duplicate_vhid` (an entry twice), or `bad_payload` / `summary_too_long` for `atomic` or `summary`. Nothing ran. Or the request sent a key and a session cookie together (`bearer_with_cookie`): send one. Or the Idempotency-Key is not 1–255 printable ASCII characters (`bad_idempotency_key`).
- `401`: No key was sent (`auth_required`), or the key was refused: unknown, revoked or expired, its holder may no longer use keys, or this instance takes none. Carries `WWW-Authenticate: Bearer`. After 60 refused keys from one IP address in a minute, every key from there is answered `429` (text, with `Retry-After`) until the minute is up.
- `403`: A read-only key tried to change something (`read_only_key`), or the account is blocked (`forbidden`).
- `409`: The first request with this Idempotency-Key is still running (`idempotency_key_in_progress`): try again after `Retry-After` seconds.
- `413`: The body is over 25 × 512 KiB. (One edit over 512 KiB is that edit’s `too_large`, 413.)
- `422`: An atomic batch was refused whole: every refused edit named, the rest `not_applied`. Nothing was written. Or this Idempotency-Key was used for a different request in the last 24 hours (`idempotency_key_reused`): nothing was written.
- `429`: Too many changes in the last minute: counted per IP address, per account and per key (an admin key’s per key alone, at the limit set on Console → Rate limits). Wait the number of seconds `Retry-After` gives.
- `503`: The site is down for maintenance. The body is the maintenance page, as HTML. Try again later: `Retry-After` gives the seconds.

---

[API](https://skmtc.dev/verifiedhandles/apis/verified-handles-api.md) · [All operations](https://skmtc.dev/verifiedhandles/apis/verified-handles-api/llms.txt) · [OpenAPI document](https://skmtc.dev/verifiedhandles/apis/verified-handles-api/revisions/40720de2e0ec?raw)
