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

# Terms of service

> Show a member the current terms, record that they accepted them, and optionally require it before they can redeem.

Members can be asked to accept the Expys terms of service inside your own app. Three
calls: find out what they owe, fetch the text, record the acceptance.

```
GET  /v1/terms                    → which documents are outstanding
GET  /v1/terms/{version}/content  → the exact text of one
POST /v1/terms/acceptance         → record that they accepted
```

<Note>
  These methods are available from **0.7.1**. On earlier versions the endpoints work over
  raw HTTP but the SDKs carry no method for them.
</Note>

## Versions are immutable

Every published document is a **version** with its own id. A version is never edited: when
the text changes, a new version is published and the old one stays exactly as it was.

That is what makes an acceptance mean something. `acceptedAt` on its own tells you *when*
someone tapped a button; an acceptance against a version tells you **what they read**.

A member who accepted last year's terms shows as **outstanding** against this year's -
which is the point.

## What a member owes

`GET /v1/terms` is metadata only and safe to call on every launch. It does not return the
document text, which is around 28kB.

<CodeGroup>
  ```ts TypeScript theme={null}
  const { documents } = await expys.getTerms();
  const outstanding = documents.filter((d) => d.acceptedAt === null);
  ```

  ```swift Swift theme={null}
  let terms = try await client.getTerms()
  let outstanding = terms.documents.filter { $0.acceptedAt == nil }
  ```

  ```kotlin Kotlin theme={null}
  val terms = client.getTerms()
  val outstanding = terms.documents.filter { it.acceptedAt == null }
  ```

  ```bash cURL theme={null}
  curl https://api.expys.com/v1/terms \
    -H "Authorization: Bearer YOUR_MEMBER_TOKEN"
  ```
</CodeGroup>

```json theme={null}
{
  "documents": [
    { "type": "PRIVACY_POLICY",  "version": "ldv_9f2...", "publishedAt": "2026-02-09T15:49:25.402Z", "acceptedAt": null },
    { "type": "TERMS_OF_SERVICE", "version": "ldv_4a1...", "publishedAt": "2026-02-10T19:15:36.179Z", "acceptedAt": null }
  ]
}
```

<ResponseField name="acceptedAt" type="string | null" required>
  When this member accepted **this exact version**. `null` means outstanding, including
  when they accepted an earlier version of the same document.
</ResponseField>

## Fetching the text

<CodeGroup>
  ```ts TypeScript theme={null}
  const { contentHTML, renderedHash } = await expys.getTermsContent(doc.version);
  ```

  ```swift Swift theme={null}
  let content = try await client.getTermsContent(version: doc.version)
  ```

  ```kotlin Kotlin theme={null}
  val content = client.getTermsContent(doc.version)
  ```

  ```bash cURL theme={null}
  curl https://api.expys.com/v1/terms/ldv_4a1.../content \
    -H "Authorization: Bearer YOUR_MEMBER_TOKEN"
  ```
</CodeGroup>

The document names **your organisation** as counterparty, substituted in at render time.
Because a version never changes and your organisation is fixed, this response is stable
forever and is served with `Cache-Control: immutable` - fetch it once when the sheet
opens, not on every launch.

`renderedHash` is the sha256 of exactly the html returned, which is what we store against
the acceptance.

## Recording acceptance

<CodeGroup>
  ```ts TypeScript theme={null}
  await expys.acceptTerms({ versions: [doc.version] });
  ```

  ```swift Swift theme={null}
  _ = try await client.acceptTerms(AcceptTermsRequest(versions: [doc.version]))
  ```

  ```kotlin Kotlin theme={null}
  client.acceptTerms(AcceptTermsRequest(versions = listOf(doc.version)))
  ```

  ```bash cURL theme={null}
  curl -X POST https://api.expys.com/v1/terms/acceptance \
    -H "Authorization: Bearer YOUR_MEMBER_TOKEN" \
    -H "Content-Type: application/json" \
    -d '{ "versions": ["ldv_4a1...", "ldv_9f2..."] }'
  ```
</CodeGroup>

The response is the same shape as `GET /v1/terms`, so you can render the outcome without
a second call. Accepting a version already on file is a **no-op**, not an error.

<Note>
  **Either credential works, and we record which.** A member token means the member
  accepted in an app holding their own credential. An Org-API-Key with `externalUserID`
  means your server is asserting they accepted somewhere in your flow. Both are
  legitimate; they are not equally strong evidence, so we do not conflate them.

  We deliberately record **no IP address**. The caller is your backend or your app behind
  your infrastructure, so an IP would be yours or a proxy's - evidentially worthless, and
  personal data neither of us needs to hold.
</Note>

## Requiring acceptance before redemption

Off by default. When Expys enables it for your organisation, `POST /v1/redemptions`
refuses a member who has not accepted the current terms:

```json theme={null}
{
  "error": {
    "code": "TERMS_NOT_ACCEPTED",
    "message": "This member must accept the current terms of service before redeeming.",
    "details": { "type": "TERMS_OF_SERVICE", "version": "ldv_4a1..." }
  }
}
```

The error carries the version, so you can open the acceptance sheet straight from it
without a second call to find out which document is missing.

Nothing is created when this fires - no booking, no inventory held, no points debited.
The member accepts and retries.

<Note>
  Only the **terms of service** gate a redemption. The privacy policy is listed and can be
  accepted, but never blocks a booking.
</Note>

## Errors

| Code                       | Status | Meaning                                                                                                                                        |
| -------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `TERMS_NOT_ACCEPTED`       | 422    | The member must accept the current terms first. `details` names the version.                                                                   |
| `ORG_LEGAL_ENTITY_MISSING` | 422    | Your organisation's registered legal entity is not set, and the terms name it as counterparty. Expys sets this during onboarding - contact us. |
| `NOT_FOUND`                | 404    | No such version.                                                                                                                               |
