> 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/conventions.md).

# Conventions

How every Shortcut API request and response is shaped - authentication, JSON, envelopes, pagination, dates, money, and versioning.

After this page you can read any Shortcut API response and write any request without checking each endpoint's quirks, because there are none: every endpoint follows the rules below.

## Base URL and versions

```
https://api.shortcutco.com/v1
```

The version is in the path. Within `v1`, Shortcut adds endpoints, fields, enum values, and optional parameters as they ship and lists each one in the [changelog](/developers/resources/changelog.md); build your client to ignore fields and enum values it does not know. A change that would break a working client goes to `/v2`, and `v1` keeps working alongside it. An endpoint or field scheduled for removal answers with `Deprecation` and `Sunset` headers at least six months ahead, and the changelog names its replacement.

The machine-readable description of every endpoint is public and needs no token:

```bash
curl https://api.shortcutco.com/v1/openapi.json
```

It is OpenAPI 3.1. Generate a typed client from it, import it into Postman or Insomnia, or hand it to an AI coding assistant.

## Environments

Every example in these pages uses the production addresses. Workspaces on Shortcut's dev environment, `dev.shortcutco.com`, use the same API and MCP server at their own addresses, with tokens created in that environment's Settings (they start `scut_dev_`):

|                                     | Production                                                        | Dev                                                               |
| ----------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------- |
| REST API                            | `https://api.shortcutco.com/v1`                                   | `https://dev.shortcutco.com/api/platform/v1`                      |
| MCP server                          | `https://api.shortcutco.com/mcp`                                  | `https://dev.shortcutco.com/api/mcp`                              |
| OpenAPI description                 | `https://api.shortcutco.com/v1/openapi.json`                      | `https://dev.shortcutco.com/api/platform/v1/openapi.json`         |
| MCP protected-resource metadata     | `https://api.shortcutco.com/.well-known/oauth-protected-resource` | `https://dev.shortcutco.com/.well-known/oauth-protected-resource` |
| OAuth authorization server (issuer) | `https://api.shortcutco.com`                                      | `https://dev.shortcutco.com`                                      |

Sign-in clients read the issuer from the protected-resource metadata and its own metadata at `<issuer>/.well-known/oauth-authorization-server`, so they need only the MCP server's address. Paths after the base are identical: `GET /v1/me` in production is `GET https://dev.shortcutco.com/api/platform/v1/me` on dev. A token works only in the environment that created it. Keep the base URL in one setting, so moving between environments is a one-line change:

```bash
export SHORTCUT_API="https://dev.shortcutco.com/api/platform/v1"
curl "$SHORTCUT_API/me" -H "Authorization: Bearer $SHORTCUT_TOKEN"
```

## Requests

* Send the token as `Authorization: Bearer scut_...` on every request. See [Workspaces and tokens](/developers/concepts/workspaces-and-tokens.md).
* Send JSON bodies with `Content-Type: application/json`, up to 1 MB.
* Field names are camel case: `firstName`, `servicesRequested`, `updatedSince`.
* Request bodies are strict: a field the endpoint does not accept is a `400 validation_failed`, so a typo never silently drops data.
* Call the API from a server. Tokens are secrets, and the API sends no CORS headers.

## Responses

A single object comes back in `data`:

```bash
curl https://api.shortcutco.com/v1/tags/2f1e0d9c-8b7a-4c6d-9e5f-4a3b2c1d0e9f \
  -H "Authorization: Bearer $SHORTCUT_TOKEN"
```

```json
{
  "data": {
    "id": "2f1e0d9c-8b7a-4c6d-9e5f-4a3b2c1d0e9f",
    "object": "tag",
    "name": "Corner lot",
    "createdAt": "2026-03-11T15:20:07.114Z",
    "updatedAt": "2026-03-11T15:20:07.114Z"
  }
}
```

A list comes back in `data` as an array, with paging in `meta`. Errors come back in `error`; see [Errors](/developers/concepts/errors.md).

Responses carry these headers:

| Header                                                      | Meaning                                                                                                                                                                                     |
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `x-request-id`                                              | A unique id for this request. Log it; quote it to support.                                                                                                                                  |
| `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` | On responses that pass the rate limit: the tightest rate-limit window you are in. See [Idempotency, retries, and rate limits](/developers/concepts/idempotency-and-retries.md#rate-limits). |
| `Cache-Control: no-store`                                   | Responses hold your business's data; nothing caches them.                                                                                                                                   |

Fields are always present. A value Shortcut does not have is `null`, never a missing key, so `customer.email === null` is how you test for "no email on file".

## Writes

* `POST` creates a record or runs an action on one: `POST /v1/leads`, `POST /v1/jobs/{id}/schedule`.
* `PATCH` changes only the fields you send. Send `null` to clear a field that allows it.
* Actions that are already done change nothing and return the record as it is: dismissing a dismissed lead, or pausing a job again with the same values.
* `POST` writes take an `Idempotency-Key`; see [Idempotency](/developers/concepts/idempotency-and-retries.md).

```bash
curl https://api.shortcutco.com/v1/jobs/a4f1c9e2-7b3d-4e8a-9c5f-1d2e3f4a5b6c \
  -X PATCH \
  -H "Authorization: Bearer $SHORTCUT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Weekly mowing and trim" }'
```

```json
{
  "data": {
    "id": "a4f1c9e2-7b3d-4e8a-9c5f-1d2e3f4a5b6c",
    "object": "job",
    "name": "Weekly mowing and trim",
    "description": null,
    "status": "scheduled",
    "customerId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
    "propertyId": "1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
    "address": "412 Elm St, Bozeman, MT 59715",
    "serviceId": "c9d8e7f6-a5b4-4c3d-9e2f-1a0b9c8d7e6f",
    "estimateId": null,
    "cadence": "recurring",
    "cadenceFrequency": 1,
    "cadencePeriod": "per_week",
    "priceStructure": "flat",
    "rate": 45,
    "quantity": 1,
    "subtotal": 45,
    "teamId": "6c7d8e9f-0a1b-4c2d-8e3f-4a5b6c7d8e9f",
    "effectiveTeamId": "6c7d8e9f-0a1b-4c2d-8e3f-4a5b6c7d8e9f",
    "nextVisitId": "5e0c8a1f-2b7d-4c3e-9f6a-8d1b2c3e4f50",
    "nextVisitDate": "2026-10-23",
    "pausedUntil": null,
    "pauseReason": null,
    "previousVersionJobId": null,
    "effectiveUntilDate": null,
    "externalReferences": [],
    "createdAt": "2026-04-02T15:10:31.204Z",
    "updatedAt": "2026-10-17T15:42:09.117Z"
  }
}
```

## Pagination

Every list uses cursor pagination:

| Query parameter | Meaning                                                                        |
| --------------- | ------------------------------------------------------------------------------ |
| `limit`         | Items per page, 1 to 200. Defaults to 50.                                      |
| `cursor`        | The `meta.nextCursor` from the previous page. Leave it out for the first page. |

```bash
curl "https://api.shortcutco.com/v1/customers?status=active&limit=2" \
  -H "Authorization: Bearer $SHORTCUT_TOKEN"
```

```json
{
  "data": [
    {
      "id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
      "object": "customer",
      "firstName": "Robin",
      "lastName": "Alvarez",
      "companyName": null,
      "email": "robin.alvarez@example.com",
      "phone": "(406) 555-0142",
      "status": "active",
      "tags": [{ "id": "2f1e0d9c-8b7a-4c6d-9e5f-4a3b2c1d0e9f", "name": "Corner lot" }],
      "externalReferences": [{ "source": "hubspot", "externalId": "51234" }],
      "createdAt": "2026-10-15T22:41:09.807Z",
      "updatedAt": "2026-10-16T14:02:55.310Z"
    },
    {
      "id": "4c3b2a19-0f8e-4d7c-9b6a-5e4d3c2b1a09",
      "object": "customer",
      "firstName": "Dana",
      "lastName": "Okafor",
      "companyName": "Okafor Dental",
      "email": "office@okafordental.example",
      "phone": "(406) 555-0198",
      "status": "active",
      "tags": [],
      "externalReferences": [],
      "createdAt": "2026-10-14T18:05:33.020Z",
      "updatedAt": "2026-10-14T18:05:33.020Z"
    }
  ],
  "meta": {
    "nextCursor": "eyJ2IjoxLCJyZXNvdXJjZSI6InBsYXRmb3JtLmN1c3RvbWVycyJ9.k3Jq",
    "hasMore": true
  }
}
```

Ask for the next page with the same filters and the cursor:

```bash
curl "https://api.shortcutco.com/v1/customers?status=active&limit=2&cursor=eyJ2IjoxLCJyZXNvdXJjZSI6InBsYXRmb3JtLmN1c3RvbWVycyJ9.k3Jq" \
  -H "Authorization: Bearer $SHORTCUT_TOKEN"
```

Keep going until `meta.hasMore` is `false`; on the last page `meta.nextCursor` is `null`.

* Lists are ordered newest first (`createdAt`, then `id`). With `updatedSince` they are ordered by last change, oldest first; see [Sync](/developers/concepts/sync.md).
* A cursor belongs to the filters it was issued for. Changing a filter while passing an old cursor is `400 invalid_cursor`; drop the cursor to start over with new filters.
* Treat cursors as opaque strings. Their format can change at any time.
* Lists report no total count. Page until `hasMore` is `false`.

## Filters

Filters are query parameters, and every one narrows the list: `GET /v1/customers?status=active&tagId=…` returns active customers with that tag. Every list's filters are on its endpoint in the [API reference](/developers/api-reference/reference.md). Dates in filters follow the same rules as dates in responses.

## Dates and times

| Kind      | Format                                   | Example                    |
| --------- | ---------------------------------------- | -------------------------- |
| Timestamp | ISO 8601 in UTC                          | `2026-10-16T17:04:12.381Z` |
| Date      | `YYYY-MM-DD` in the workspace's timezone | `2026-10-16`               |

A visit's `date` is the day it is on the schedule where the business is, not in UTC: a 6 PM visit in Denver on October 16 has `"date": "2026-10-16"` even though its timestamps are October 17 in UTC. `GET /v1/me` returns `workspace.timezone` (an IANA name such as `America/Denver`) and `workspace.today`, so your code never has to work out "today" for the business.

One exception: an invoice's `dueDate`, and the `dueBefore` filter, use the calendar date printed on the invoice. `dueAt` is the exact time the invoice turns `overdue`.

Timestamp filters such as `updatedSince` accept any ISO 8601 timestamp with an offset, such as `2026-10-16T09:00:00-06:00` or `2026-10-16T15:00:00Z`.

## Money

Money is a decimal number of US dollars with at most two decimal places: `129.5` is $129.50. All amounts are USD. Parse amounts with a decimal type, or as integer cents, before adding them up.

```bash
curl https://api.shortcutco.com/v1/invoices/d3c2b1a0-9f8e-4d7c-8b6a-5f4e3d2c1b0a \
  -H "Authorization: Bearer $SHORTCUT_TOKEN"
```

```json
{
  "data": {
    "id": "d3c2b1a0-9f8e-4d7c-8b6a-5f4e3d2c1b0a",
    "object": "invoice",
    "number": 1042,
    "kind": "standard",
    "status": "unpaid",
    "paymentActivity": null,
    "customerId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
    "propertyId": "1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
    "propertyAddress": "412 Elm St, Bozeman, MT 59715",
    "propertyLabel": "Home",
    "subtotal": 120,
    "salesTax": 9.5,
    "processingFee": 0,
    "processingFeeScope": "all_payments",
    "cardProcessingFee": 0,
    "lateFees": 0,
    "total": 129.5,
    "dueDate": "2026-10-31",
    "dueAt": "2026-10-31T00:00:00.000Z",
    "coverageStartDate": null,
    "coverageEndDate": null,
    "sentAt": "2026-10-16T17:05:02.000Z",
    "paidAt": null,
    "createdAt": "2026-10-16T17:02:20.114Z",
    "updatedAt": "2026-10-16T17:05:02.310Z",
    "lines": [
      {
        "id": "b1a29384-7c6d-4e5f-8a9b-0c1d2e3f4a5b",
        "type": "service",
        "name": "Weekly mowing",
        "description": null,
        "date": "2026-10-16",
        "amount": 120,
        "taxable": true,
        "quantity": null,
        "unitPrice": null,
        "unitLabel": null,
        "visitId": "5e0c8a1f-2b7d-4c3e-9f6a-8d1b2c3e4f50",
        "serviceId": "c9d8e7f6-a5b4-4c3d-9e2f-1a0b9c8d7e6f"
      }
    ]
  }
}
```

`total` is `subtotal + salesTax + processingFee + lateFees`.

## Phone numbers

Send phone numbers as people write them. US numbers may leave out the country code: `(406) 555-0142`, `406-555-0142`, and `+1 406 555 0142` are the same number. Leads return phone numbers in E.164 form, such as `+14065550142`. Customers return the number as it was entered in the app.

## Deleting

Records are rarely deleted in Shortcut, and the API follows the app: customers are deactivated, leads are dismissed, visits are canceled or skipped, invoices are voided. Each of those is a `status` change you can see in lists and sync.
