> ## Documentation Index
> Fetch the complete documentation index at: https://docs.outseta.com/llms.txt
> Use this file to discover all available pages before exploring further.

# API overview

> Authentication, field selection, pagination, filtering, errors, rate limits and webhooks.

The Outseta REST API lets you manage people, accounts, subscriptions, invoices, email, and support resources over HTTPS. Responses are JSON unless an endpoint documents another format, such as an invoice PDF.

**Base URL:** `https://{your-domain}.outseta.com/api/v1/`

## Contents

* [Quick start](#quick-start)
* [Authentication](#authentication)
  * [Server-side API keys](#server-side-api-keys)
  * [Client-side bearer tokens](#client-side-bearer-token)
  * [Two-factor authentication](#two-factor-authentication-2fa-during-login)
  * [Forced 2FA enrollment](#forced-2fa-enrollment-during-login)
* [Working with collections](#working-with-collections)
  * [Field selection](#field-selection)
  * [Pagination](#pagination)
  * [Sorting](#sorting)
  * [Filtering](#filtering)
  * [Sorting and filtering metadata](#sorting-and-filtering-metadata)
* [Account billing stages](#account-billing-stages)
* [Error responses](#error-responses)
* [Rate limits](#rate-limits)
* [Support](#support)
* [Webhooks](#webhooks)

## Quick start

Authenticate every request with either a server-side API key or a user bearer token. For example:

```http theme={null}
GET /api/v1/crm/accounts?limit=20&fields=Uid,Name HTTP/1.1
Host: example.outseta.com
Authorization: Outseta {api_key}:{api_secret}
```

List endpoints return a consistent envelope:

```json theme={null}
{
  "metadata": {
    "limit": 20,
    "offset": 0,
    "total": 142
  },
  "items": []
}
```

Use `limit` and `offset` for [pagination](#pagination), `fields` to control the response shape, `orderBy` for sorting, and entity properties for filtering.

General request behavior:

* Use HTTPS. HTTP requests are redirected to HTTPS with `301`.
* `GET` retrieves, `POST` creates or invokes an action, `PUT` updates, and `DELETE` removes.
* The API returns standard HTTP status codes. Error details are returned as JSON.
* Add `donotlog=1` to a request that should not appear in the activity log.

## Authentication

All API requests require authentication via one of two methods:

### Server-Side (API Keys)

Use API keys for server-to-server integrations. Create keys at **Settings > Integrations > API Keys**. Make sure to record the secret key when you create it.

```
Authorization: Outseta {api_key}:{api_secret}
```

**Example:**

```
Authorization: Outseta ce08fd5a-e1ee-4472-9c5f-b7575d8369b2:74fc1d2242a4eb7336d34b0e40cfbc5f
```

> **Warning:** Never expose API keys in client-side code. The API key and secret combined give full access to all data in your account.

### Client-Side (Bearer Token)

Do **not** use API keys on the client side — they can be easily copied. Instead, obtain a JWT access token by calling the `POST /tokens` endpoint from the server side with your credentials, then use it on the client:

```
Authorization: bearer {access_token}
```

Tokens are JWTs containing claims such as `PersonUid`, `AccountUid`, and subscription details. Tokens expire after approximately one year.

Verify tokens server-side using Outseta's [JWKS endpoint](https://\{your-domain}.outseta.com/.well-known/jwks.json).

### Two-Factor Authentication (2FA) During Login

When a user has enabled two-factor authentication, a username and password are
no longer sufficient to obtain a token — the login becomes a two-step exchange.
You do not need to call any endpoint up front to discover whether 2FA is on; the
response to `POST /tokens` tells you.

**Step 1 — Attempt login as usual.**

```
POST /api/v1/tokens
Content-Type: application/json

{ "username": "user@example.com", "password": "their-password" }
```

If the user has no 2FA enabled, you get the normal `200` token response and
you're done:

```json theme={null}
{ "access_token": "eyJ...", "token_type": "Bearer", "expires_in": 31536000 }
```

If the user **does** have 2FA enabled, the password is verified and the response
is `202 Accepted` with a challenge instead of a token:

```json theme={null}
{
  "two_factor_required": true,
  "challenge_token": "eyJ...",
  "mechanism": "Totp",
  "masked_destination": "",
  "expires_in": 600,
  "available_mechanisms": ["Totp", "Email"],
  "recovery_codes_available": true
}
```

This single response answers everything you need to know about the user's 2FA:

| Field | Meaning |
| - | - |
| `mechanism` | The method this challenge targets: `Email` or `Totp` (authenticator app). **If `Email`, a one-time code has already been emailed to the user.** If `Totp`, nothing is sent — the user reads the current code from their authenticator app. |
| `masked_destination` | Where an emailed code was sent, masked for display (e.g. `b***@outseta.com`). Empty when `mechanism` is `Totp`. |
| `available_mechanisms` | Every method the user has enrolled. Use this to show the user their options (and to enable "use a different method"). |
| `recovery_codes_available` | Whether the user has recovery codes they can fall back to. |
| `challenge_token` | Opaque, short-lived token (valid for `expires_in` seconds, 600 = 10 minutes) that ties the next request to this challenge. Echo it back verbatim. |

> When both an authenticator app and email are enrolled, Outseta picks the
> authenticator app (`Totp`) as the default `mechanism` because it requires no
> "check your inbox" step. The user can switch to email — see below.

**Step 2 — Submit the code to get the token.**

Collect the 6-digit code from the user (from their email or authenticator app)
and post it along with the `challenge_token`:

```
POST /api/v1/tokens/two-factor
Content-Type: application/json

{ "challenge_token": "eyJ...", "code": "123456" }
```

On success you receive the final JWT — the same shape as a normal login, and the
token you use as the `Authorization: bearer {access_token}` for subsequent calls:

```json theme={null}
{ "access_token": "eyJ...", "token_type": "Bearer", "expires_in": 31536000 }
```

Error responses:

| Status | Body | Meaning |
| - | - | - |
| `400` | `invalid_grant` | The code was wrong or missing. The challenge allows up to 5 attempts before it locks. |
| `410` | `challenge_expired` | The challenge token expired (older than 10 minutes). Start over at `POST /tokens`. |
| `429` | — | Too many attempts; retry after a minute. |

**Optional helper endpoints** (all take the `challenge_token` from Step 1):

* **Resend an emailed code** — `POST /api/v1/tokens/two-factor/resend` with
  `{ "challenge_token": "..." }`. Returns a fresh challenge (same shape as the
  `202` above). Only valid for `Email` challenges; a `Totp` challenge returns
  `400` `not_supported`.
* **Switch to a different method** — `POST /api/v1/tokens/two-factor/switch-mechanism`
  with `{ "challenge_token": "...", "mechanism": "Email" }`. Use this when the
  user picks one of the other `available_mechanisms` (e.g. they can't reach their
  authenticator). Returns a fresh challenge for the chosen mechanism (and sends a
  code when switching to `Email`).
* **Use a recovery code** — `POST /api/v1/tokens/two-factor/recovery` with
  `{ "challenge_token": "...", "recovery_code": "abcd-efgh-ijkl" }`. Returns the
  final JWT, exactly like Step 2. Each recovery code is single-use.

### Forced 2FA Enrollment During Login

Accounts can be configured to *require* 2FA. When a user who has not yet set it
up tries to log in, they must enroll a method before they can get a token —
all in the same login flow.

**Step 1 — Attempt login.** Exactly as above. If the account forces 2FA and the
user has no method enrolled, `POST /api/v1/tokens` verifies the password and
returns `202 Accepted` with `two_factor_enrollment_required` (instead of
`two_factor_required`):

```json theme={null}
{
  "two_factor_enrollment_required": true,
  "challenge_token": "eyJ...",
  "expires_in": 600
}
```

That `challenge_token` is the **enrollment token** — it proves the user's
identity for the rest of the flow. Pass it as `enrollment_token` in every call
below. Let the user pick a method (email or an authenticator app) and run the
matching two-step *begin → confirm* sequence.

**Option A — Enroll an authenticator app (recommended).**

1. Begin — exchange the enrollment token for a shared secret:

   ```
   POST /api/v1/tokens/two-factor/enroll/totp/begin
   { "enrollment_token": "eyJ..." }
   ```

   ```json theme={null}
   {
     "challenge_token": "eyJ...",
     "secret": "JBSWY3DPEHPK3PXP",
     "otpauth_uri": "otpauth://totp/Acme:user@example.com?secret=...&issuer=Acme",
     "qr_code_png_base64": "iVBORw0KGgo...",
     "expires_in": 600
   }
   ```

   Have the user add the secret to their authenticator app — scan
   `qr_code_png_base64` (render it as an image), open `otpauth_uri`, or type the
   `secret` manually.

2. Confirm — submit the first code the app generates:

   ```
   POST /api/v1/tokens/two-factor/enroll/totp/confirm
   { "enrollment_token": "eyJ...", "challenge_token": "eyJ...", "code": "123456" }
   ```

**Option B — Enroll email.**

1. Begin — a code is emailed to the user:

   ```
   POST /api/v1/tokens/two-factor/enroll/email/begin
   { "enrollment_token": "eyJ..." }
   ```

   ```json theme={null}
   { "challenge_token": "eyJ...", "mechanism": "Email", "masked_destination": "b***@outseta.com", "expires_in": 600 }
   ```

2. Confirm — submit the emailed code:

   ```
   POST /api/v1/tokens/two-factor/enroll/email/confirm
   { "enrollment_token": "eyJ...", "challenge_token": "eyJ...", "code": "123456" }
   ```

**Confirm response (both options).** A successful confirm enables the method
*and* completes the login in one shot — it returns the final JWT plus the user's
recovery codes:

```json theme={null}
{
  "confirmed": true,
  "recovery_codes": ["abcd-efgh-ijkl", "mnop-qrst-uvwx", "..."],
  "access_token": "eyJ...",
  "token_type": "Bearer",
  "expires_in": 31536000
}
```

> Show `recovery_codes` to the user **once** and prompt them to save the codes —
> they are generated as part of first-time enrollment and are never returned
> again. Each is single-use at `POST /api/v1/tokens/two-factor/recovery`.

Notes:

* Both `begin` calls pass only the `enrollment_token`; both `confirm` calls pass
  the `enrollment_token`, the `challenge_token` from the matching `begin`, and the
  `code`.
* `401` means the enrollment token is missing/invalid/expired (restart at Step 1);
  `403` means forced enrollment does not apply to the user; `400` (`invalid_grant`)
  means a wrong code; `410` (`challenge_expired`) means the challenge timed out.
* Each token/challenge is valid for 10 minutes (`expires_in` = 600). If the user
  takes too long, restart at `POST /api/v1/tokens`.
* After enrollment, the user is a normal 2FA user — subsequent logins follow the
  standard challenge flow described above, not this enrollment flow.

## Working with collections

Collection endpoints support field selection, pagination, sorting, and filtering. Unless an endpoint says otherwise, these options can be combined in the same request.

### Field selection

When you make an API request, you'll automatically get all the basic information from the main object and its immediate child objects. Referenced objects beyond the first level are returned as `null`.

Change this behavior using the `fields` query parameter:

* **Go deeper** — Request fields lower down in the object tree: `?fields=CurrentSubscription.Plan.*`
* **Go lighter** — Request only the essentials for faster performance: `?fields=Uid,Name`
* **Combination** — `?fields=Uid,Name,CurrentSubscription.Plan.Uid`
* **Wildcard** — Use `*` to get all fields in an object: `?fields=*` or `?fields=CurrentSubscription.Plan.*`

> **Tip:** When expanding nested paths, include `*` and intermediate path segments (e.g., `PersonAccount.*`) to preserve root-level and intermediate fields.

**Examples:**

```
# Get the current subscription plan UID for an account
GET /crm/accounts/{uid}?fields=CurrentSubscription.Plan.Uid

# Get the account UID and plan UID for a list of accounts
GET /crm/accounts?fields=Uid,CurrentSubscription.Plan.Uid

# Get the full plan object for an account's current subscription
GET /crm/accounts/{uid}?fields=CurrentSubscription.Plan.*

# Get a person with their account and subscription info
GET /crm/people/{uid}?fields=Uid,PersonAccount.Account.CurrentSubscription.Plan.Uid
```

> **Unknown paths are ignored, not rejected.** A `fields` path that does not match a property on the object is dropped from the request. The response is still `200`, still well-formed, and simply has no such property — the same shape you would get if the value were legitimately `null`. Nothing in the response distinguishes "you asked for a property that does not exist" from "that property is null". Confirm that what you requested came back before relying on it, and check spelling against the endpoint's response schema rather than a similarly named property on another object.

If your request includes fields from a child object you will be limited to retrieving **25 items** per page. The maximum for requests not expanding child object fields is **100 items**.

### Pagination

List endpoints return paginated results with a `metadata` object and an `items` array:

```json theme={null}
{
  "metadata": {
    "limit": 25,
    "offset": 0,
    "total": 142
  },
  "items": [ ... ]
}
```

| Parameter | Type | Default | Description |
| - | - | - | - |
| `offset` | integer | `0` | Zero-based page number, not a record offset |
| `limit` | integer | `25` | Requested page size. Capped at `100`, or `25` when requested fields expand child objects or require additional queries; `metadata.limit` reports the applied value |

**Examples:**

```
?offset=0&limit=20     # page 1 (index 0) — results 1-20
?offset=1&limit=20     # page 2 (index 1) — results 21-40
?offset=20&limit=20    # page index 20 — results 401-420, NOT results 21-40
```

### Sorting

Sort results using the `orderBy` parameter with a property name and direction:

```
?orderBy=PropertyName+DESC
```

`orderBy` generally supports database-backed scalar properties. Support for nested paths is endpoint-specific, especially when a path traverses a collection and does not identify a single ordering value. Important known unsupported paths, including calculated properties, are listed in the endpoint's `x-outseta-sort-denylist`; this list is curated rather than exhaustive.

### Filtering

Filter results by passing entity properties as query parameters.

#### Basic filtering

Filter on any field using the field name as a query parameter:

```
GET /crm/people?Email=john@example.com
GET /crm/accounts?AccountStage=2
```

#### Wildcard matching

| Pattern | Match Type | Example |
| - | - | - |
| `*value` | Ends with | `?Email=*@example.com` |
| `value*` | Starts with | `?Name=Acme*` |
| `*value*` | Contains | `?Name=*corp*` |

#### Comparison operators

For advanced filtering, append comparison operators to field names:

| Operator | Description | Example |
| - | - | - |
| `__gt` | Greater than | `Created__gt=2024-01-01` |
| `__gte` | Greater than or equal | `Amount__gte=100` |
| `__lt` | Less than | `Created__lt=2024-12-31` |
| `__lte` | Less than or equal | `Amount__lte=500` |
| `__ne` | Not equal | `Status__ne=Active` |
| `__isnull` | Is null (true/false) | `ProfileImageS3Url__isnull=true` |

**Examples:**

```
# Date filtering
GET /crm/accounts?Created__gt=2024-01-01
GET /billing/subscriptions?EndDate__lt=2024-08-01

# Numeric filtering
GET /billing/invoices?Amount__gte=1000

# Null value filtering
GET /crm/people?ProfileImageS3Url__isnull=true

# Multiple filters combined with field selection
GET /billing/subscriptions?StartDate__gte=2024-01-01&EndDate__isnull=true&fields=Uid,StartDate,EndDate,Plan.Name
```

### Sorting and filtering metadata

Collection operations in the OpenAPI document can include `x-outseta-sort-denylist` and `x-outseta-filter-denylist`. Each extension is an array of property paths, relative to an item in that operation's response collection, that the operation does not support for sorting or filtering. Paths can be nested.

```yaml theme={null}
x-outseta-sort-denylist:
  - CurrentSubscription.Rate
x-outseta-filter-denylist:
  - CurrentSubscription.Rate
```

These lists currently prioritize high-value financial and CRM properties that API consumers frequently attempt to query. They are curated rather than exhaustive, so the absence of a property is not by itself a guarantee that every query shape is supported.

## Account Billing Stages

Account stages reflect the financial standing of each account and are not directly editable — they change automatically based on subscription activity.

| Value | Stage | Description |
| - | - | - |
| `2` | Trialing | Currently on a free trial or free plan |
| `3` | Subscribing | Active paid subscription (contributes to MRR) |
| `4` | Canceling | Customer has indicated intent to cancel |
| `5` | Expired | Subscription has ended after cancellation |
| `6` | Trial Expired | Free trial ended without conversion to paid |

Filter by stage: `GET /crm/accounts?AccountStage=3` returns all actively subscribing accounts.

## Error Responses

| Status Code | Description |
| - | - |
| `200` | Success |
| `301` | Redirect — HTTP requests are redirected to HTTPS |
| `400` | Bad request — invalid parameters or Uid format |
| `401` | Unauthorized — missing or invalid authentication |
| `404` | Entity not found |
| `4XX` / `5XX` | Client or server error |

Validation errors return a JSON body with `ErrorMessage` and `PropertyName` fields:

```json theme={null}
{
  "ErrorMessage": "Invalid company email",
  "PropertyName": "Email"
}
```

## Rate Limits

Requests authorized by an API Key should not exceed **4 requests/second**.

## Support

For help regarding the Outseta API please email [support@outseta.com](mailto:support@outseta.com).

## Webhooks

Use **Activity Notifications** to receive real-time callbacks when events occur in Outseta. Configure webhook URLs and their SHA256 signing secrets at **Settings > Notifications**. Always verify the signature before processing a request.

Outseta sends an HTTP `POST` to each registered callback URL. The payload is the activity's entity restricted to a subset of fields, with an `ActivityEventData` property carrying activity-specific data.

On accounts that bill through Stripe, account payloads carry `CurrentStripeProductInstances`: the Stripe products the account owns right now, each with the owned `Quantity`. The `AccountProductsChanged` event fires when that list changes and names the added and removed product UIDs in `ActivityEventData`.

| Activity | Entity | Payload schema |
| - | - | - |
| `AccountCreated` | `Account` | `AccountCreatedWebhookPayload` |
| `AccountUpdated` | `Account` | `AccountUpdatedWebhookPayload` |
| `AccountAddPerson` | `Account` | `AccountAddPersonWebhookPayload` |
| `AccountStageUpdated` | `Account` | `AccountStageUpdatedWebhookPayload` |
| `AccountDeleted` | `Account` | `AccountDeletedWebhookPayload` |
| `AccountBillingInformationUpdated` | `Account` | `AccountBillingInformationUpdatedWebhookPayload` |
| `AccountSubscriptionPlanUpdated` | `Account` | `AccountSubscriptionPlanUpdatedWebhookPayload` |
| `AccountSubscriptionPaymentCollected` | `Account` | `AccountSubscriptionPaymentCollectedWebhookPayload` |
| `AccountSubscriptionPaymentDeclined` | `Account` | `AccountSubscriptionPaymentDeclinedWebhookPayload` |
| `AccountBillingInformationRequested` | `Account` | `AccountBillingInformationRequestedWebhookPayload` |
| `AccountBillingInvoiceEmailSent` | `Invoice` | `AccountBillingInvoiceEmailSentWebhookPayload` |
| `AccountRemovePerson` | `Account` | `AccountRemovePersonWebhookPayload` |
| `AccountPaidSubscriptionCreated` | `Account` | `AccountPaidSubscriptionCreatedWebhookPayload` |
| `AccountBillingInformationRemoved` | `Account` | `AccountBillingInformationRemovedWebhookPayload` |
| `AccountPrimaryPersonUpdated` | `Account` | `AccountPrimaryPersonUpdatedWebhookPayload` |
| `AccountBillingInvoiceCreated` | `Invoice` | `AccountBillingInvoiceCreatedWebhookPayload` |
| `AccountSubscriptionStarted` | `Account` | `AccountSubscriptionStartedWebhookPayload` |
| `AccountSubscriptionRenewalExtended` | `Account` | `AccountSubscriptionRenewalExtendedWebhookPayload` |
| `AccountSubscriptionAddOnsChanged` | `Account` | `AccountSubscriptionAddOnsChangedWebhookPayload` |
| `AccountSubscriptionCancellationRequested` | `Account` | `AccountSubscriptionCancellationRequestedWebhookPayload` |
| `AccountBillingInvoiceDeleted` | `Invoice` | `AccountBillingInvoiceDeletedWebhookPayload` |
| `AccountPersonRoleUpdated` | `Account` | `AccountPersonRoleUpdatedWebhookPayload` |
| `AccountProductsChanged` | `Account` | `AccountProductsChangedWebhookPayload` |
| `PersonCreated` | `Person` | `PersonCreatedWebhookPayload` |
| `PersonUpdated` | `Person` | `PersonUpdatedWebhookPayload` |
| `PersonDeleted` | `Person` | `PersonDeletedWebhookPayload` |
| `PersonLogin` | `Account` | `PersonLoginWebhookPayload` |
| `PersonListSubscribed` | `Person` | `PersonListSubscribedWebhookPayload` |
| `PersonListUnsubscribed` | `Person` | `PersonListUnsubscribedWebhookPayload` |
| `PersonSegmentAdded` | `Person` | `PersonSegmentAddedWebhookPayload` |
| `PersonSegmentRemoved` | `Person` | `PersonSegmentRemovedWebhookPayload` |
| `PersonEmailOpened` | `Person` | `PersonEmailOpenedWebhookPayload` |
| `PersonEmailClicked` | `Person` | `PersonEmailClickedWebhookPayload` |
| `PersonEmailBounce` | `Person` | `PersonEmailBounceWebhookPayload` |
| `PersonEmailSpam` | `Person` | `PersonEmailSpamWebhookPayload` |
| `PersonSupportTicketCreated` | `Person` | `PersonSupportTicketCreatedWebhookPayload` |
| `PersonSupportTicketUpdated` | `Person` | `PersonSupportTicketUpdatedWebhookPayload` |
| `PersonLeadFormSubmitted` | `Person` | `PersonLeadFormSubmittedWebhookPayload` |
| `PersonListConfirmed` | `Person` | `PersonListConfirmedWebhookPayload` |
| `PersonEmailSubscribed` | `Person` | `PersonEmailSubscribedWebhookPayload` |
| `PersonEmailUnsubscribed` | `Person` | `PersonEmailUnsubscribedWebhookPayload` |
| `PersonTemporaryPasswordSet` | `Person` | `PersonTemporaryPasswordSetWebhookPayload` |
| `PersonSupportTicketClosed` | `Person` | `PersonSupportTicketClosedWebhookPayload` |
| `PersonTwoFactorRecoveryCodesRegenerated` | `Person` | `PersonTwoFactorRecoveryCodesRegeneratedWebhookPayload` |
| `DealCreated` | `Deal` | `DealCreatedWebhookPayload` |
| `DealUpdated` | `Deal` | `DealUpdatedWebhookPayload` |
| `DealDeleted` | `Deal` | `DealDeletedWebhookPayload` |
| `DealDueDate` | `Deal` | `DealDueDateWebhookPayload` |
| `TaskCreated` | `Deal` or `Person` or `Account` | `TaskCreatedWebhookPayload` |
| `TaskUpdated` | `Deal` or `Person` or `Account` | `TaskUpdatedWebhookPayload` |
| `PlanCreated` | `Plan` | `PlanCreatedWebhookPayload` |
| `PlanUpdated` | `Plan` | `PlanUpdatedWebhookPayload` |
| `AddOnCreated` | `AddOn` | `AddOnCreatedWebhookPayload` |
| `AddOnUpdated` | `AddOn` | `AddOnUpdatedWebhookPayload` |
| `DiscordUserLinked` | `Person` | `DiscordUserLinkedWebhookPayload` |
| `DiscordUserAddedToServer` | `Person` | `DiscordUserAddedToServerWebhookPayload` |
| `DiscordUserRolesUpdated` | `Person` | `DiscordUserRolesUpdatedWebhookPayload` |
| `DiscordUserRemovedFromServer` | `Person` | `DiscordUserRemovedFromServerWebhookPayload` |

An activity that lists more than one entity is rooted on whichever of those entities it fires on, so its payload schema is a `oneOf` of them. `TaskCreated` and `TaskUpdated` work this way: the webhook is rooted on the deal, person or account the task belongs to, and the task itself is carried in the `ActivityEventData` property.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.