> For the complete documentation index, see [llms.txt](https://docs.fuul.xyz/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.fuul.xyz/developer-guide/terms-conditions.md).

# Agreements & Terms

Projects can require users to accept one or more documents before participating in a program: terms & conditions, program terms, a privacy policy, a risk disclosure. Each document is an **agreement** addressed by a `key`. Fuul records the acceptance once per wallet and document, and makes it readable from any surface holding a project API key, so a user who already accepted in your app is not prompted again in the affiliate portal.

{% hint style="info" %}
Agreements are configured by Fuul, not from the dashboard. Reach out at <ecosystem@fuul.xyz> to set up your documents. For each one, send a name, the document URL (`https://` only), and the `key` you want to use (lowercase letters, numbers, and hyphens, such as `general` or `risk-disclosure`).
{% endhint %}

## How it works

1. Your frontend calls `GET /{key}/status` for the connected wallet
2. If `accepted` is `false`, you show the document at `document_url` and ask the user to accept
3. Your backend calls `POST /{key}/accept`, declaring which surface collected the acceptance
4. Every other surface reading `/{key}/status` for that wallet now sees `accepted: true`

Which documents a surface requires is up to your frontend. Fuul keeps the list of documents and the record of who accepted what.

The record attests that a system asserted the acceptance. Neither endpoint carries a user session, and nothing cryptographically binds the wallet to the request, so this is not a signature scheme. If you need proof of possession, verify the wallet yourself before calling `/accept`.

## Endpoints

Base path: `/api/v1/project-agreements`

| Endpoint        | Method | Required scope                                           | Description                                             |
| --------------- | ------ | -------------------------------------------------------- | ------------------------------------------------------- |
| `/{key}/accept` | POST   | `agreements:write`                                       | Records an acceptance of that document. Returns `201`   |
| `/{key}/status` | GET    | `agreements:read`, `agreements:write`, or `service_role` | Returns the document plus one wallet's acceptance state |

Both endpoints return the same response shape, so an accept never needs a follow-up read. A `key` that does not exist on your project returns `404`.

### Request fields

| Field                  | Where       | Description                                                           |
| ---------------------- | ----------- | --------------------------------------------------------------------- |
| `user_identifier`      | Both        | The wallet address                                                    |
| `user_identifier_type` | Both        | `evm_address`, `solana_address`, or `xrpl_address`                    |
| `source`               | Accept only | `partner_site`, `affiliates_portal`, or `other`. Required, no default |

{% hint style="warning" %}
The accepted identifier types are **narrower than the platform's full list**. Only `evm_address`, `solana_address`, and `xrpl_address` are valid here. `sui_address` is rejected with a `400` even though it appears in identifier type lists elsewhere in these docs, and `email` / `uuid` are rejected as well: an acceptance is anchored to a wallet.
{% endhint %}

### Response

```json
{
  "key": "general",
  "name": "Referral Program Terms",
  "description": null,
  "document_url": "https://yourproject.com/terms-v2.pdf",
  "accepted": true,
  "accepted_at": "2026-07-23T13:52:18.000Z"
}
```

| Field          | Description                                                                   |
| -------------- | ----------------------------------------------------------------------------- |
| `key`          | The document's key                                                            |
| `name`         | The document's display name                                                   |
| `description`  | Optional description. `null` if not set                                       |
| `document_url` | The document's **current** URL, which is what you should show now             |
| `accepted`     | Whether this wallet has already accepted this document                        |
| `accepted_at`  | When the acceptance was recorded, on the server clock. `null` if not accepted |

## Example

Check status before showing your program UI:

```typescript
const res = await fetch(
  `https://api.fuul.xyz/api/v1/project-agreements/general/status?user_identifier=${address}&user_identifier_type=evm_address`,
  { headers: { Authorization: `Bearer ${apiKey}` } }
);

const status = await res.json();

if (!status.accepted) {
  showTermsModal(status.name, status.document_url);
}
```

Record the acceptance from your backend:

```bash
curl -X POST https://api.fuul.xyz/api/v1/project-agreements/general/accept \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer your-api-key" \
  -d '{
    "user_identifier": "0x1234...",
    "user_identifier_type": "evm_address",
    "source": "partner_site"
  }'
```

## Idempotency

Acceptance is unique per `(project, document, wallet, identifier type)`. Calling `/accept` again for a wallet that already accepted that document returns `201` and leaves the original record untouched: `source`, the document URL, and `accepted_at` stay frozen to whichever surface accepted first.

This means you can call `/accept` without checking `/status` first, and retries are safe.

## What acceptance does and does not cover

|                      | Behavior                                                                                                                                                                                                        |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Scope**            | One acceptance per wallet and document. Accepting one document says nothing about the others                                                                                                                    |
| **New documents**    | Adding a document to your project does not affect existing acceptances. Users accept it separately                                                                                                              |
| **Document changes** | Each record snapshots the document URL that was live when the user accepted, while `/status` always returns the current URL. Publish every revision under a new immutable URL so that snapshot stays meaningful |
| **Re-prompting**     | Changing a document's URL or text does **not** re-prompt users who already accepted it. To collect a fresh acceptance, add the revision as a new document with its own `key`                                    |

{% hint style="warning" %}
If you overwrite your terms document at the same URL instead of publishing a new one, the stored records will point at a document whose contents have changed since users accepted it. Use a versioned path such as `/terms-v2.pdf`.
{% endhint %}

## Legacy routes

Integrations built before per-document agreements use `/api/v1/project-terms-conditions`. These routes still work, with their contract unchanged:

| Endpoint                                  | Method | Required scope                             |
| ----------------------------------------- | ------ | ------------------------------------------ |
| `/api/v1/project-terms-conditions/accept` | POST   | `terms_conditions:write`                   |
| `/api/v1/project-terms-conditions/status` | GET    | `terms_conditions:write` or `service_role` |

They take the same request fields but name no document, so they act on your project's **default** agreement. Both route families write the same records: a wallet that accepted through the legacy routes shows `accepted: true` on `/{key}/status` for the default document, and the other way around.

```json
{
  "terms_acceptance_required": true,
  "terms_document_url": "https://yourproject.com/terms-v2.pdf",
  "terms_accepted": true,
  "accepted_at": "2026-07-23T13:52:18.000Z"
}
```

`terms_acceptance_required` is `true` when your project has a default agreement. Without one, `/status` answers with `terms_acceptance_required: false` and `/accept` returns `400`.

{% hint style="info" %}
New integrations should use the per-document routes. Keys issued with `terms_conditions:write` keep working against the legacy routes; to call the new ones, create a key with the `agreements:read` or `agreements:write` scope in **Settings > API keys**.
{% endhint %}

## Related

* Collecting tax forms from affiliates is a separate flow with its own gate → [Tax Information](/core-concepts/affiliates/tax-information.md)
* Programs that require users to accept individual payouts before claiming → [Claim Flow Integration](/developer-guide/claim-frontend.md)
