# Errors and idempotency keys

> Pumati MCP error codes and how idempotency keys make retries safe.

A failed call is a tool error (`isError: true`) whose text is JSON:

```
{ "error": "INVALID_INPUT", "message": "invalid fields", "fields": ["ends_at"] }
```

Arguments that don't match a tool's input schema (a missing field, a value out of range, an unknown enum value such as `status: verified_by_human`) are `INVALID_INPUT` too. `fields` names the inputs to fix as dotted paths (`bonus.amount_each`, `options.1.label`) and comes only with `INVALID_INPUT`; `message` says what is wrong in English.

| Code | Meaning and what to do |
| --- | --- |
| `INVALID_INPUT` | An input is missing, out of range or not allowed; `fields` names which, and `message` says why when that is known (e.g. an image that isn't valid base64 or isn't a PNG, JPEG or WebP). Fix them and call again. |
| `NOT_FOUND` | No such app, mission, build or feedback in the apps this connection may use (other apps look the same as missing ones), or no tool with that name. |
| `FORBIDDEN` | The connection doesn't allow this scope on that app, may not create apps, or was revoked (then connect again), or the account is suspended. |
| `SUBSCRIPTION_REQUIRED` | The account has no active Indie or Team plan. Choose one at pumati.app/dev/credits. |
| `INVALID_STATE` | The mission isn't in a state that allows this, e.g. publishing one that is already published. |
| `DRAFT_CHANGED` | The draft changed since `expected_revision`. Read it again with `get_test` and publish with the new revision. |
| `REVISION_CHANGED` | The feedback was revised since the `revision_no` you reviewed. Read it again with `get_feedback` and review the new revision. |
| `CLAIMED` | A Pumati reviewer is working on this feedback right now. Try again later or leave it to them. |
| `SPEND_LIMIT` | The budget doesn't fit in what is left of this connection's spend limit. Raise it by reconnecting, or publish smaller. |
| `INSUFFICIENT_CREDITS` | The app owner's balance can't cover the budget. Buy credits at pumati.app/dev/credits. |
| `BUILD_REQUIRED` | A targeted platform has no linked build. Register one and link it in `build_ids`. |
| `BUILD_NOT_APPROVED` | A linked iOS build is still waiting for Apple's beta review. |
| `BUILD_EXPIRES_TOO_SOON` | A linked build expires before the mission ends. End earlier or link a newer build. |
| `IDEMPOTENCY_MISMATCH` | This `idempotency_key` was used with different input. Use a new key. |
| `IDEMPOTENCY_NOT_GRANTED` | This key created an app this connection can't use. Use a new key. |
| `INTERNAL` | Something went wrong on our side. Retry with the same `idempotency_key`. |

## Idempotency keys

Every write and publish tool takes an optional `idempotency_key`: 1 to 128 characters of `A-Z a-z 0-9 . _ : -`. A key is scoped to your account **and the OAuth client** that sends it: the same key from a different client (Codex after Claude Code, say) is a new request and creates a new resource. Signing in again with the same client keeps its keys; a client that registers anew (for example after removing and re-adding the server) counts as a different client.

- The same key with the same input returns the first result without doing it again, so a retry after a timeout never publishes twice.
- The same key with different input is `IDEMPOTENCY_MISMATCH`: use a new key for a new request.
- A call that failed stores nothing, so you can retry it with the same key.
- Concurrent calls with one key wait for the first and return its result.

Use a key that describes the intent, e.g. `publish-<mission_id>-<revision>`.

## Draft revisions

Drafts carry a `draft_revision`. `publish_test` takes the revision you last saw as `expected_revision`; if someone edited the draft since (on the web, for example) it fails with `DRAFT_CHANGED`. Call `get_test`, check the draft, and publish with the new revision.
