> For the complete documentation index, see [llms.txt](https://docs.shortcutco.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.shortcutco.com/developers/concepts/idempotency-and-retries.md).

# Idempotency, retries, and rate limits

Retry any write safely with Idempotency-Key or the MCP requestKey, stay inside the rate limits, and trace a request with its request id.

After this page your integration can retry any failed request without creating duplicates, back off correctly when it is rate limited, and hand support a request id that finds the exact call.

## Idempotency keys

A network can fail after Shortcut has done the work but before your code hears back. Retrying blindly would create a second lead. An idempotency key makes the retry safe: Shortcut remembers the first answer and returns it again instead of repeating the write.

Send an `Idempotency-Key` header on `POST` and `PUT` requests. Any string up to 255 characters works; a UUID, or an id from your own system such as a form submission id, is ideal.

```bash
curl https://api.shortcutco.com/v1/leads \
  -X POST \
  -H "Authorization: Bearer $SHORTCUT_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: webform-submission-48213" \
  -d '{
    "firstName": "Jamie",
    "lastName": "Chen",
    "email": "jamie.chen@example.com",
    "address": "88 Willow Way, Bozeman, MT 59718",
    "servicesRequested": ["Aeration"]
  }'
```

Send the same request with the same key again and you get the first response back, with the same status and body and one extra header:

```
HTTP/2 201
idempotent-replayed: true
```

What Shortcut does with a key it has seen:

| The earlier request with this key                                                                                                                                    | What a new request with the same key gets                                                 |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| Was refused before the write ran: a `401`, `402`, or `429`, a `403` for a missing scope, or a `400` for a malformed request                                          | Nothing was stored. The new request runs normally.                                        |
| Ran and succeeded                                                                                                                                                    | The same status and body, replayed, with `Idempotent-Replayed: true`. Nothing runs again. |
| Ran and was refused by a rule of the write itself with a `4xx`, such as an unusable phone number or a lead whose email belongs to a customer the token cannot change | The same refusal, replayed with `Idempotent-Replayed: true`.                              |
| Is still running                                                                                                                                                     | `409 idempotency_key_in_use` with `Retry-After: 1`. Wait and retry with the same key.     |
| Ran and failed with a `5xx`, or was rate limited while running                                                                                                       | Nothing was stored. The retry runs as a new attempt.                                      |
| Was a different request (another endpoint, path, query, or body)                                                                                                     | `422 idempotency_key_reused_with_different_payload`. Nothing runs.                        |

Keys to remember:

* **Keys last 24 hours.** After that a key is forgotten and can run again. Retry within a day.
* **Keys belong to one token.** Two tokens never collide, even with the same key.
* **After a refusal, send the corrected request with a new key.** Most mistakes, such as a missing field, are caught before the write runs and store nothing. A few are caught while it runs, such as an unusable phone number, and that refusal is stored with the key, so resending the corrected body with the old key gets `422 idempotency_key_reused_with_different_payload`. A new key is right in both cases.
* **Reads need no key.** `GET` requests change nothing and are always safe to repeat.

## requestKey on MCP tools

MCP write tools, such as `create_lead`, take an optional `requestKey` argument with exactly the same meaning as `Idempotency-Key`. An agent that retries a tool call with the same `requestKey` gets the first result back, marked in the result's `_meta` with `"com.shortcutco/idempotentReplayed": true`, instead of creating a second record.

```json
{
  "jsonrpc": "2.0",
  "id": 7,
  "method": "tools/call",
  "params": {
    "name": "create_lead",
    "arguments": {
      "firstName": "Jamie",
      "lastName": "Chen",
      "email": "jamie.chen@example.com",
      "address": "88 Willow Way, Bozeman, MT 59718",
      "servicesRequested": ["Aeration"],
      "requestKey": "4f9b2c1e-7a3d-4e8f-b6c5-0d1e2f3a4b5c"
    }
  }
}
```

## Rate limits

Shortcut counts requests in one-minute sliding windows:

| Window                                        | Limit            |
| --------------------------------------------- | ---------------- |
| Requests per token                            | 600 per minute   |
| Writes per token                              | 120 per minute   |
| Requests per workspace, across all its tokens | 2,000 per minute |
| Writes per workspace, across all its tokens   | 300 per minute   |
| Leads created through the API per workspace   | 20 per minute    |
| Failed authentications per IP address         | 30 per minute    |

Writes are `POST`, `PUT`, `PATCH`, and `DELETE`. MCP tool calls count exactly like the REST request they run, and MCP messages such as `initialize` and `tools/list` count as reads.

REST responses that pass the rate limit report the tightest window you are in:

| Header                | Meaning                          |
| --------------------- | -------------------------------- |
| `RateLimit-Limit`     | Requests allowed in that window. |
| `RateLimit-Remaining` | Requests left in it.             |
| `RateLimit-Reset`     | Seconds until it resets.         |

`GET /v1/me` returns the same numbers in `data.rateLimit`. On the MCP server, a tool result that asks you to wait carries the seconds in `_meta["com.shortcutco/retryAfterSeconds"]`.

Past a limit, the answer is `429` with a `Retry-After` header in seconds and a code that says whose limit it was: `too_many_requests` for this token, `workspace_rate_limited` for the workspace as a whole. The workspace limit is shared, so adding tokens does not add capacity; spread heavy jobs out instead.

## Retrying

| Answer                                   | Retry? | How                                                                |
| ---------------------------------------- | ------ | ------------------------------------------------------------------ |
| Network error or timeout                 | Yes    | Same request, same idempotency key.                                |
| `409 idempotency_key_in_use`             | Yes    | After `Retry-After`, same key.                                     |
| `429`                                    | Yes    | After `Retry-After` seconds, same key.                             |
| `500 internal_error`                     | Yes    | With backoff, same key.                                            |
| `503`                                    | Yes    | After `Retry-After`, same key.                                     |
| `400`, `401`, `402`, `403`, `404`, `422` | No     | Fix the cause first. See [Errors](/developers/concepts/errors.md). |
| `409 already_exists`                     | No     | The record is already there.                                       |

Back off exponentially with jitter between attempts (for example 1, 2, 4, 8 seconds, each plus or minus a random fraction), honor `Retry-After` when it is present, and give up after a handful of attempts. A minimal retrying client:

```js
// Node 18 or later.
export async function shortcutRequest(path, { method = "GET", body, idempotencyKey } = {}) {
  const headers = { Authorization: `Bearer ${process.env.SHORTCUT_TOKEN}` };
  if (body !== undefined) headers["Content-Type"] = "application/json";
  if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;

  for (let attempt = 0; ; attempt += 1) {
    let response;
    try {
      response = await fetch(`https://api.shortcutco.com${path}`, {
        method,
        headers,
        body: body === undefined ? undefined : JSON.stringify(body),
      });
    } catch (networkError) {
      if (attempt >= 4) throw networkError;
      await sleep(backoff(attempt));
      continue;
    }

    const payload = await response.json();
    const retryable = response.status === 409 || response.status === 429 || response.status >= 500;
    if (response.ok || !retryable || attempt >= 4 || payload.error?.code === "already_exists") {
      return { status: response.status, payload, requestId: response.headers.get("x-request-id") };
    }
    const retryAfter = Number(response.headers.get("retry-after"));
    await sleep(Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : backoff(attempt));
  }
}

const backoff = (attempt) => 2 ** attempt * 1000 * (0.75 + Math.random() / 2);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
```

Call it with one key per intended write:

```js
import { randomUUID } from "node:crypto";

const { status, payload } = await shortcutRequest("/v1/leads", {
  method: "POST",
  idempotencyKey: randomUUID(),
  body: { firstName: "Jamie", lastName: "Chen", email: "jamie.chen@example.com", address: "88 Willow Way, Bozeman, MT 59718" },
});
```

## Request ids

Every response, success or error, carries an `x-request-id` header, and every error body repeats it as `error.requestId`. Shortcut generates it; log it next to your own record of the call. When something looks wrong, send the request id to Shortcut support and they can find the exact request, its outcome, and its timing without you sharing any customer data.
