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

# Organizations, users, and teams

> Discover your organizations and manage shared team filters and membership through REST.

A shared team has **members** and **one saved filter**. The filter defines which records match.
Membership determines whose `mine=true` includes that filter. Teams grant no additional resource
access. An organization member with Write access can manage any shared team in that organization;
Read access is enough to inspect teams, rules, and users.

## Discover an organization

Start without an organization header:

```bash theme={null}
curl --fail-with-body "$API_URL/v1/organizations" \
  -H "Authorization: Bearer $API_KEY"
```

The response is `{ "data": [{ "id": "org_...", "name": "Example" }], "next_cursor": null }`.
Follow `next_cursor` with `cursor` until it is null. The default page size is 50; the maximum is 100.
Organizations are ordered by name, then ID.

Discovery requires a valid key or signed-in Clerk session, but no selected organization. A supplied
`X-Organization-Id` is ignored for this endpoint. Only synchronized memberships with a supported
organization role appear. Selected keys remain within their selected set; migrated keys remain
within their original organization. No memberships produces an empty page.

Discovery does not enforce organization MFA or grant resource access. For subsequent requests,
choose a returned organization ID and send `X-Organization-Id`. All normal role, key-access, and MFA
checks apply. Clerk sessions must first select that organization as active, and any supplied header
must match it. Membership changes appear after Clerk webhook synchronization.

## Find members and create a team

`GET /v1/members` lists the selected organization's current members,
including `user_id`, name, email, role and join date. Use `user_id` when adding a team member.
`GET /v1/teams/{id}/members` returns paginated `{id, first_name, last_name, email}` records.
Both lists are ordered by user ID. Names and email can be null. User and organization IDs are opaque strings; team IDs
are UUIDs. Pending invitations are not members.

```bash theme={null}
export ORGANIZATION_ID='<organization-id>'

api() {
  curl --fail-with-body "$API_URL$1" \
    -H "Authorization: Bearer $API_KEY" \
    -H "X-Organization-Id: $ORGANIZATION_ID" \
    -H 'Content-Type: application/json' "${@:2}"
}

api "/v1/members"
TEAM_ID=$(api /v1/teams -X POST --data '{"name":"Purchasing"}' | jq -r '.id')
```

Creation returns `201` and a `Location` header. Names are trimmed, limited to 100 characters, and
unique case-sensitively within the organization. Repeating a name returns `409 conflict`.
The team initially has no members and no filter. You may provide a filter during creation.

`GET /v1/teams` remains a compact name-searchable selector with `id`, `name`, and
`organization_number: null`. Retrieve `/v1/teams/{id}` for the saved `filter`, `created_at`, and
`updated_at`; retrieve its `/members` endpoint for membership.

## Define the predicate

A **condition** has `field`, `op`, and `value`. A **group** combines conditions or nested groups
using `combinator: "and"` or `"or"`. There is no implicit precedence or group-level NOT.
The version-1 definition has `schema_version: 1` and a root group. Maximums are **20 conditions**
and **three group levels**, including the root. Empty groups and empty value lists are invalid.

This selects **supplier A or B, and recipient C**. Replace the placeholders with actual UUIDs:

```json theme={null}
{
  "filter": {
    "schema_version": 1,
    "root": {
      "combinator": "and",
      "children": [
        {
          "field": "supplier.id",
          "op": "is_any_of",
          "value": ["<supplier-a-uuid>", "<supplier-b-uuid>"]
        },
        { "field": "recipient.id", "op": "is", "value": "<recipient-c-uuid>" }
      ]
    }
  }
}
```

Use `is` / `is_not` for one exact value and `is_any_of` / `is_none_of` for a list. All four accept
both a scalar and a nonempty array for compatibility. A positive list means **any listed value**;
a negative list excludes **every listed value**. Reference values must identify records in the
selected organization, including when used with negative operators.

### Fields and operators

| Field                       | Value                                                      | Operators                                                        |
| --------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------- |
| `supplier.id`               | Supplier UUID or UUID array                                | `is`, `is_not`, `is_any_of`, `is_none_of`                        |
| `recipient.id`              | Recipient UUID or UUID array                               | Same exact operators                                             |
| `agreement.id`              | Agreement UUID or UUID array                               | Same exact operators                                             |
| `agreement.tag`             | Tag UUID or UUID array, not tag names                      | Same exact operators                                             |
| `supplier.country`          | Country text, typically uppercase ISO code                 | Exact operators; `contains`, `not_contains`                      |
| `invoice.currency`          | Currency text, typically uppercase ISO code                | Exact operators; `contains`, `not_contains`                      |
| `invoice.reference`         | Reference text                                             | Exact operators; `contains`, `not_contains`                      |
| `invoice.delivery_address`  | Delivery-address text                                      | Exact operators; `contains`, `not_contains`                      |
| `invoice.amount`            | Finite number or plain decimal string, such as `"1000.00"` | `eq`, `gt`, `gte`, `lt`, `lte`                                   |
| `invoice.issued_date`       | `YYYY-MM-DD` or a relative token for `within`              | `on`, `before`, `on_or_before`, `after`, `on_or_after`, `within` |
| `agreement.expiration_date` | Same date values                                           | Same date operators                                              |

Exact text comparisons are case-sensitive and do not trim or normalize stored rule values.
`contains` and `not_contains` accept a single nonempty string, are case-insensitive, and treat
`%` and `_` literally. Exact text operators also accept a nonempty string array.

`invoice.amount` uses the invoice total **including VAT**. It performs no currency conversion.
Use decimal strings when exact precision matters. Date values must be real calendar dates.
`within` accepts `last_30_days`, `last_90_days`, `last_365_days`, `expiring_30_days`,
`expiring_60_days`, or `expiring_90_days`. These ranges include both endpoints and use the current
UTC date on each request.

`invoice.reference` checks contract, buyer, seller, project, credit-note invoice and payment
references, plus exact elements of `order_references`. Substring operators do not search the
order-reference array. `invoice.delivery_address` checks delivery name, street, postal code, city,
state, and country. A positive condition matches any supported component; a negative condition
requires every supported component to avoid the value.

### Which records are evaluated?

| Condition                     | Invoices           | Agreements        | Alerts                    | Claims                                    |
| ----------------------------- | ------------------ | ----------------- | ------------------------- | ----------------------------------------- |
| Supplier                      | Invoice supplier   | Linked suppliers  | Alert supplier            | Claim agreement's suppliers               |
| Recipient                     | Invoice recipient  | Linked recipients | Alert invoice's recipient | Claim agreement's recipients              |
| Agreement ID, tag, expiration | Matched agreements | Agreement itself  | Alert's own agreement     | Claim's own agreement                     |
| Invoice fields                | Invoice itself     | Matched invoices  | Alert's own invoice       | Invoices matched to the claim's agreement |

**Conditions over related records are evaluated independently.** For example, an agreement linked
to a USD 2,000 invoice and a NOK 100 invoice matches `invoice.amount > 1000 AND invoice.currency is NOK`.
One invoice satisfies each condition; no single invoice needs to satisfy both. Grouping those
conditions with AND does not introduce a same-invoice requirement. The same principle applies to
conditions over multiple matched agreements or linked suppliers. Version 1 has no same-related-record group.

For related collections, a positive condition needs a matching related record; a negative condition
means no related record matches its corresponding positive condition. Empty relationships therefore
satisfy negative conditions. On nullable scalar fields, negative exact/substring conditions include
null, while positive, numeric, and date comparisons require a value. Normal resource visibility and
deletion rules still apply.

An alert uses its own agreement: another agreement matched to the same invoice cannot satisfy its
agreement rules. Claims match through their agreement even without attached alerts. Team predicates
select complete claims; they do not trim claim members or refund totals.

## Save a supplier / recipient / tag rule

Resolve IDs through `/v1/suppliers`, `/v1/recipients`, and `/v1/agreements/tags`. Follow their
pagination when selecting records. With your chosen UUIDs in the variables below:

```bash theme={null}
export SUPPLIER_ID='<supplier-uuid>'
export RECIPIENT_ID='<recipient-uuid>'
export TAG_ID='<tag-uuid>'

RULE=$(jq -n --arg supplier "$SUPPLIER_ID" --arg recipient "$RECIPIENT_ID" --arg tag "$TAG_ID" '{
  filter: {schema_version: 1, root: {combinator: "or", children: [
    {field: "supplier.id", op: "is", value: $supplier},
    {field: "recipient.id", op: "is", value: $recipient},
    {field: "agreement.tag", op: "is", value: $tag}
  ]}}
}')
api "/v1/teams/$TEAM_ID" -X PATCH --data "$RULE"
```

This selects records matching **any** of the three conditions. PATCH replaces the **complete filter**
when supplied. Omit `filter` to preserve it, or send `{"filter":null}` to clear it. Clearing matches
no records; it does not mean match everything. Name and filter edits commit together.

Invalid writes return `400 validation_error` with up to 20 details identifying fields such as
`filter.root.children.0`. Foreign references are reported as unavailable without disclosing foreign
records. Read responses preserve malformed legacy JSON so it can be inspected. Replace or clear it
to repair it; an unrelated name edit leaves it unchanged. Selecting a team with unsupported stored
rules returns `409 conflict` until those rules are corrected.

## Add a member and observe the scope

For `mine=true`, add the authenticated key owner or session user. Their ID is available in `/v1/me`:

```bash theme={null}
USER_ID=$(api /v1/me | jq -r '.principal.user_id')
api "/v1/teams/$TEAM_ID/members" -X POST \
  --data "$(jq -n --arg user "$USER_ID" '{user_id:$user}')"

api '/v1/invoices?mine=true'
api '/v1/invoices/metrics?mine=true'
api "/v1/agreements?team_ids=$TEAM_ID"
api '/v1/alerts?mine=true'
api '/v1/alerts/metrics?mine=true'
api '/v1/alerts/groups?mine=true&group_by=agreement'
api '/v1/claims?mine=true'
api '/v1/claims/metrics?mine=true'
```

Adding another current organization user works the same way using `user_id` from `/v1/members`.
New membership returns `201`; repeated addition returns the existing user with `200`.
Missing/foreign users or teams and personal subscriptions return `404`.

`mine=true` unions the user's shared team predicates with **OR**. Explicit `team_ids` unions the
selected teams, regardless of membership. Ordinary query filters narrow that union with **AND**.
Selecting the same record through multiple teams does not duplicate records or totals.
`mine=true` and `team_ids` together return `400`; explicit selection accepts up to 50 teams.
No memberships or no saved predicates produces empty lists/groups and zero metrics.

Changes to rules and memberships apply on subsequent requests, including requests using an existing
cursor. Pagination remains live: keep the same user, organization, query, and ordering. There is no
snapshot, automatic restart, or scope version. Cursors cannot be transferred between queries.

## Remove membership or delete the team

```bash theme={null}
api "/v1/teams/$TEAM_ID/members/$USER_ID" -X DELETE
api "/v1/teams/$TEAM_ID" -X DELETE
```

Membership removal returns `204`, including when a current organization user is already absent.
Removing the last member is allowed; the team and its filter remain usable through `team_ids`.

Team deletion returns `204` and permanently removes the team, saved filter, and membership links.
It does not delete organization users or business records. There is no restore. Missing or already
deleted teams return `404`. The deleted team stops contributing to `mine`; explicit `team_ids`
containing it returns `404`, including on later pages. Personal subscriptions are outside this API.
