# Payees

A **payee** is a registered counterparty under your seller account — a sub-merchant, marketplace seller, or any other recipient that is eligible to receive payouts and split shares. Every payout and split rule line targets a payee.

Payees have two API surfaces with different auth models:

| Surface | Auth | Operations |
|---------|------|------------|
| REST `/payments-api/v1/payees` | Bearer token | Full CRUD: list, get, create, update, delete |
| GraphQL `/dashboard/graphql` | `dashboard_session` cookie | Full CRUD: list, get, create, update, delete |

> [!NOTE]
> The REST surface is the integrator-facing API. Every REST payee operation is scoped to the seller account behind your Bearer token: the `sellerAccountId` and `legalEntityId` are derived from the token and are **never** read from the request body, and a payee belonging to another seller returns `404` (so payee IDs cannot be probed across sellers). The GraphQL surface is the backoffice/dashboard API (cookie-authenticated, legal-entity scoped) and exposes the same operations plus split-rule-line attachment.

---

## Payee fields (from `schema.graphql`)

### `type Payee`

| Field | Type | Description |
|-------|------|-------------|
| `id` | `Int!` | Numeric payee ID — use this as the `payeeId` when creating payout methods |
| `legalEntityId` | `Int!` | Legal entity this payee belongs to |
| `sellerAccountId` | `Int!` | Your seller account ID |
| `displayName` | `String` | Human-readable name shown in the dashboard |
| `role` | `PayeeRole!` | `Contractor` or `Tax` for payees you create; `Itself` (the seller's own payee) and `fynex_platform` are assigned by Fynex. `Seller` is a deprecated legacy value — do not send it |
| `status` | `PayeeStatus!` | `Active` or `Disabled` |
| `isActive` | `Boolean!` | Convenience flag derived from `status` |
| `email` | `String` | Contact email |
| `phone` | `String` | Contact phone |
| `businessName` | `String` | Legal business name |
| `businessType` | `String` | Type of business entity |
| `businessAddress` | `String` | Registered business address |
| `taxId` | `String` | Tax identification number |
| `payeeContractId` | `String` | Your internal contract reference |
| `wallets` | `[Wallet!]!` | Wallets assigned to this payee |
| `createdAt` | `Time!` | Creation timestamp |
| `updatedAt` | `Time!` | Last update timestamp |

---

## REST — Full CRUD

**Auth: Bearer token** (`Authorization: Bearer <token>`)

| Operation | Method & path |
|-----------|---------------|
| List payees | `GET /payments-api/v1/payees?limit={n}&offset={n}` |
| Get a payee | `GET /payments-api/v1/payees/{payee_id}` |
| Create a payee | `POST /payments-api/v1/payees` |
| Update a payee | `PATCH /payments-api/v1/payees/{payee_id}` |
| Delete a payee | `DELETE /payments-api/v1/payees/{payee_id}` |

Every operation is scoped to the seller account behind your Bearer token. `role` accepts the lowercase values `contractor` and `tax`.

> [!TIP]
> **Onboarding a payee just to pay it?** `POST /payments-api/v1/payees/setup` creates a payee **and** its first payout method in a single atomic call, so you can pay a brand-new payee in two requests total. See the [2-call quick path](https://docs.fynex.ai/payments-api/v2/docs#tag/payouts) in the Payouts guide.

### Payee response shape

All single-payee responses (`GET` / `POST` / `PATCH`) return the same object; the list endpoint wraps an array of these under `payees`:

```json
{
  "id": 101,
  "legalEntityId": 77,
  "merchantId": "16ddc5f1-39ca-4b7c-9bfa-bf5779b8c117",
  "displayName": "Acme Supplies Ltd",
  "role": "Contractor",
  "status": "Active",
  "isActive": true,
  "payeeContractId": "pc_001",
  "email": "finance@acme.example.com",
  "phone": "+447700900123",
  "businessName": "Acme Supplies Limited",
  "businessType": "limited_company",
  "businessAddress": "1 Example Street, London, GB",
  "taxId": "GB123456789",
  "createdAt": "2026-06-05T10:00:00Z",
  "updatedAt": "2026-06-05T10:00:00Z"
}
```

### List payees

```
GET /payments-api/v1/payees?limit={n}&offset={n}
```

Returns a paginated list of payees belonging to your seller account. `limit` (1–100, default 20) and `offset` (≥0) are tolerant — out-of-range or non-numeric values silently fall back to the defaults.

```bash
curl -sS "https://api.fynex.ai/payments-api/v1/payees?limit=20&offset=0" \
  -H "Authorization: Bearer $FYNEX_TOKEN"
```

```json
{ "payees": [ { "id": 101, "displayName": "Acme Supplies Ltd", "role": "Contractor", "status": "Active" } ], "total": 1, "limit": 20, "offset": 0 }
```

### Get a payee

```bash
curl -sS "https://api.fynex.ai/payments-api/v1/payees/101" \
  -H "Authorization: Bearer $FYNEX_TOKEN"
```

A payee that does not exist, or belongs to another seller, returns `404`.

### Create a payee

`sellerAccountId` and `legalEntityId` are **not** accepted in the body — they are derived from your token. A wallet in your seller account's currency is provisioned for the payee automatically.

```bash
curl -sS -X POST "https://api.fynex.ai/payments-api/v1/payees" \
  -H "Authorization: Bearer $FYNEX_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "displayName": "Acme Supplies Ltd",
    "role": "contractor",
    "payeeContractId": "pc_001",
    "email": "finance@acme.example.com",
    "businessName": "Acme Supplies Limited",
    "taxId": "GB123456789"
  }'
```

Returns `201 Created` with the payee object. A `payeeContractId` already in use within your seller account returns `409 Conflict`.

### Update a payee

`PATCH` is a partial update — send only the fields you want to change; omitted fields are left untouched.

```bash
curl -sS -X PATCH "https://api.fynex.ai/payments-api/v1/payees/101" \
  -H "Authorization: Bearer $FYNEX_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "displayName": "Acme Supplies (EU) Ltd", "email": "eu-finance@acme.example.com" }'
```

Returns `200 OK` with the updated payee object. A foreign/unknown `payee_id` returns `404`.

> [!NOTE]
> `role` is immutable after creation. Supplying a `role` that differs from the payee's current role returns `400` (`role cannot be changed after creation`) — the payee's wallet is provisioned by role at creation time and is not re-provisioned on update. Sending the same `role`, or omitting it, is fine.

#### Archive / restore via `status`

`status` accepts the lowercase values `active` and `disabled`. Setting it archives or restores the payee; `isActive` is kept in sync automatically (you do not send it):

```bash
# Archive (equivalent to DELETE, see below)
curl -sS -X PATCH "https://api.fynex.ai/payments-api/v1/payees/101" \
  -H "Authorization: Bearer $FYNEX_TOKEN" -H "Content-Type: application/json" \
  -d '{ "status": "disabled" }'

# Restore a previously archived payee
curl -sS -X PATCH "https://api.fynex.ai/payments-api/v1/payees/101" \
  -H "Authorization: Bearer $FYNEX_TOKEN" -H "Content-Type: application/json" \
  -d '{ "status": "active" }'
```

### Delete (archive) a payee

`DELETE` is **non-destructive and reversible**: it *archives* the payee by setting its status to `Disabled` (and `isActive` to `false`). The payee is **not** removed — a subsequent `GET /payees/{payee_id}` still returns it (now `Disabled`), and you can restore it at any time with `PATCH {"status":"active"}`.

```bash
curl -sS -X DELETE "https://api.fynex.ai/payments-api/v1/payees/101" \
  -H "Authorization: Bearer $FYNEX_TOKEN"
```

```json
{ "archived": true }
```

> [!WARNING]
> **Archiving a payee that is still referenced by an active split rule will cause its payouts to fail — by design.** The split/payout engine is fail-closed: it never routes funds to a `Disabled` payee. If an archived payee is still a target of an active split rule, the affected split executions and payouts **fail with an explanatory error** (funds are never partially routed or silently dropped) until you either restore the payee (`PATCH {"status":"active"}`) or remove it from the split rule. The split-rule lines themselves are left untouched by archiving, so restoring the payee cleanly resumes payouts.

Returns `200 OK`. Archiving an already-archived payee is a no-op that still returns `200`. A foreign/unknown `payee_id` returns `404`. `DELETE` is exactly equivalent to `PATCH {"status":"disabled"}` — use whichever fits your client.

---

## Hosted payee onboarding contract (ONBOARD-01)

This is the contract for the planned hosted onboarding session. **It is not a
registered public route yet.** Until the runtime session store, redirect
handler, and webhook consumer implement these guarantees, integrations must
use the existing payee verification routes (`POST /payees/{payee_id}/verification`)
or vendor verification link route. No identity or money-moving endpoint is
advertised by this contract-only slice.

### Proposed operation

```http
POST /payments-api/v1/payees/{payee_id}/onboarding-sessions
Authorization: Bearer <seller_sk_key>
Idempotency-Key: <stable-client-key>
Content-Type: application/json

{ "returnUrl": "https://merchant.example/payees/onboarding/complete" }
```

The authenticated seller owns the payee: a payee belonging to another seller
is indistinguishable from an unknown payee and returns `404`. The operation
must not accept `sellerAccountId`, `legalEntityId`, or an identity-provider
applicant id from the caller. It creates no payout method and moves no money.

The successful response is:

```json
{
  "sessionId": "pos_01J...",
  "payeeId": 101,
  "onboardingUrl": "https://onboarding.fynex.ai/payee/pos_01J...",
  "expiresAt": "2026-09-15T12:00:00Z",
  "status": "pending"
}
```

`returnUrl` is optional only when the seller has a configured default. When
provided it must be HTTPS, have no userinfo, resolve to a public address, and
match the seller's allowed-return-hosts configuration. The service validates
it before creating a session; it never reflects an unvalidated URL into a
redirect.

### Session, replay, and completion semantics

- A session is owned by exactly one seller and payee, expires at `expiresAt`,
  and is unusable after expiry (`410 onboardingSessionExpired`). Expiry is
  server-enforced, not merely a UI timer.
- `Idempotency-Key` is required and scoped to the seller and payee. The same
  key with an equivalent body returns the original session (`200`); reusing it
  with a different `returnUrl` returns `409 idempotencyConflict`. A retried
  request never creates a second provider applicant or session.
- Opening the URL is safe to repeat. The provider's hosted result is accepted
  only after a verified provider webhook updates the payee's verification
  state. A browser redirect is advisory and cannot enable payouts by itself.
- On completion, the browser is redirected to the validated `returnUrl` with
  only opaque `sessionId` and `status` parameters. No identity document,
  provider token, or secret is placed in the URL. If no return URL is usable,
  the session ends on a Fynex-hosted status page.
- The seller receives the existing payee verification webhook/event state
  transition. Webhook delivery is signed, retried until a `2xx`, and must be
  consumed idempotently; duplicate events do not create a second transition.

The route remains intentionally absent from OpenAPI until these runtime
properties have a durable implementation and authenticated integration tests.

## GraphQL — Full CRUD

**Auth: `dashboard_session` cookie** — see [GraphQL Authentication](https://docs.fynex.ai/payments-api/v2/docs#tag/graphql-auth) for the login flow.

All five operations are available on `/dashboard/graphql`:

| Operation | Type | Permission |
|-----------|------|------------|
| `payees(limit, offset)` | Query | `PAYEES_READ` |
| `payee(id: Int!)` | Query | `PAYEES_READ` |
| `createPayee(input: PayeeInput!)` | Mutation | `PAYEES_CREATE` |
| `updatePayee(id: Int!, input: PayeeInput!)` | Mutation | `PAYEES_UPDATE` |
| `deletePayee(id: Int!)` | Mutation | `PAYEES_DELETE` — **staff surface only**, not callable on `/dashboard/graphql` |

### `PayeeInput` fields

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `legalEntityId` | `Int!` | Yes | Legal entity to associate this payee with |
| `merchantId` | `ID!` | Yes | Your seller account ID, as a GraphQL `ID` (`"42"` and `42` are both accepted) |
| `role` | `PayeeRole!` | Yes | `Contractor` or `Tax` (`Itself` is reserved for the seller's own payee; `Seller` is deprecated) |
| `displayName` | `String!` | Yes | Human-readable payee name |
| `payeeContractId` | `String` | No | Your internal reference |
| `email` | `String` | No | Payee contact email |
| `phone` | `String` | No | Payee contact phone |
| `businessName` | `String` | No | Legal business name |
| `businessType` | `String` | No | Business entity type |
| `businessAddress` | `String` | No | Registered address |
| `taxId` | `String` | No | Tax identification number |

### `PayeeInput` fields

All fields are optional. Only fields you supply are updated; omit any field to leave it unchanged.

| Field | Type | Description |
|-------|------|-------------|
| `legalEntityId` | `Int!` | Legal entity (required if updating) |
| `displayName` | `String` | New display name |
| `role` | `PayeeRole` | `Contractor` or `Tax` (`Seller` is deprecated) |
| `merchantId` | `ID` | Seller account ID |
| `email` | `String` | Contact email |
| `phone` | `String` | Contact phone |
| `businessName` | `String` | Legal business name |
| `businessType` | `String` | Business entity type |
| `businessAddress` | `String` | Registered address |
| `taxId` | `String` | Tax identification number |
| `payeeContractId` | `String` | Internal contract reference |

> **Routing is not set through this mutation.** `splitRuleLine` was removed from `PayeeInput`; it had been rejected with an error since 2026-08-26, so this table promised a field that could not be used. Configure an inactive split rule, preview the saved version, then activate it — and manage individual lines with `upsertSplitRuleLineByPayee` / `deleteSplitRuleLineByPayee`.

---

## Create a payee — code samples

#### curl

```bash
# First obtain the session cookie (see GraphQL Authentication guide)
curl -sc cookies.txt \
  -X POST https://api.fynex.ai/api/v1/login/dashboard \
  -H "Content-Type: application/json" \
  -d '{"email": "you@example.com", "password": "your_password"}'

# Create the payee
curl -b cookies.txt \
  -X POST https://api.fynex.ai/dashboard/graphql \
  -H "Content-Type: application/json" \
  -d '{
    "query": "mutation CreatePayee($input: PayeeInput!) { createPayee(input: $input) { id displayName role status } }",
    "variables": {
      "input": {
        "legalEntityId": 1,
        "merchantId": "42",
        "role": "Contractor",
        "displayName": "Acme Supplies Ltd",
        "email": "payments@acme.example.com",
        "businessName": "Acme Supplies Limited",
        "taxId": "GB123456789"
      }
    }
  }'
```

#### JavaScript (Apollo/fetch)

```js
const BASE = 'https://api.fynex.ai';

// Assumes login was already called and the cookie is present
async function createPayee(input) {
  const res = await fetch(`${BASE}/dashboard/graphql`, {
    method: 'POST',
    credentials: 'include',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      query: `
        mutation CreatePayee($input: PayeeInput!) {
          createPayee(input: $input) {
            id
            displayName
            role
            status
          }
        }
      `,
      variables: { input },
    }),
  });
  const { data, errors } = await res.json();
  if (errors?.length) throw new Error(errors[0].message);
  return data.createPayee;
}

const payee = await createPayee({
  legalEntityId: 1,
  merchantId: '42',
  role: 'Contractor',
  displayName: 'Acme Supplies Ltd',
  email: 'payments@acme.example.com',
  businessName: 'Acme Supplies Limited',
  taxId: 'GB123456789',
});
console.log(payee); // { id: 7, displayName: 'Acme Supplies Ltd', role: 'Seller', status: 'Active' }
```

#### Python

```python
import requests

BASE = "https://api.fynex.ai"
session = requests.Session()

# Login first
session.post(
    f"{BASE}/api/v1/login/dashboard",
    json={"email": "you@example.com", "password": "your_password"},
).raise_for_status()

mutation = """
  mutation CreatePayee($input: PayeeInput!) {
    createPayee(input: $input) {
      id
      displayName
      role
      status
    }
  }
"""
variables = {
    "input": {
        "legalEntityId": 1,
        "merchantId": "42",
        "role": "Contractor",
        "displayName": "Acme Supplies Ltd",
        "email": "payments@acme.example.com",
        "businessName": "Acme Supplies Limited",
        "taxId": "GB123456789",
    }
}
resp = session.post(
    f"{BASE}/dashboard/graphql",
    json={"query": mutation, "variables": variables},
)
resp.raise_for_status()
body = resp.json()
if "errors" in body:
    raise RuntimeError(body["errors"][0]["message"])
print(body["data"]["createPayee"])
# {'id': 7, 'displayName': 'Acme Supplies Ltd', 'role': 'Seller', 'status': 'Active'}
```

---

## Other operations

### List payees via GraphQL

```graphql
query ListPayees($limit: Int, $offset: Int) {
  payees(limit: $limit, offset: $offset) {
    id
    displayName
    role
    status
    email
    businessName
  }
}
```

### Get a single payee

```graphql
query GetPayee($id: Int!) {
  payee(id: $id) {
    id
    displayName
    role
    status
    email
    phone
    businessName
    businessAddress
    taxId
    payeeContractId
    wallets { id currencyCode }
    createdAt
  }
}
```

### Update a payee

```graphql
mutation UpdatePayee($id: Int!, $input: PayeeInput!) {
  updatePayee(id: $id, input: $input) {
    id
    displayName
    email
    updatedAt
  }
}
```

Pass only the fields you want to change in `$input`. `legalEntityId` is required in `PayeeInput` even when you are not changing it.

### Delete a payee

```graphql
# Not available on /dashboard/graphql — staff surface only. Shown for reference;
# sellers archive a payee with the REST DELETE below.
mutation DeletePayee($id: Int!) {
  deletePayee(id: $id)
}
```

Returns `true` on success.

> [!CAUTION]
> **`deletePayee` is not exposed on `/dashboard/graphql`.** A dashboard session calling it
> receives `Cannot query field "deletePayee" on type "Mutation"`; the mutation exists on the
> Fynex staff surface only. From your integration, use the REST `DELETE /payees/{payeeId}`
> described above — it archives the payee and is reversible.

> [!WARNING]
> Where it is available, the GraphQL `deletePayee` does **not** behave like the REST `DELETE`. GraphQL performs a database soft-delete (sets `deleted_at`), which **hides** the payee from subsequent reads and is **not** restorable through the API. The REST `DELETE` instead *archives* the payee (status `Disabled`, still queryable, restorable via `PATCH {"status":"active"}`). Ensure no active payout methods or split rule lines reference the payee before calling the GraphQL mutation.

---

## Common pitfalls

| Pitfall | Resolution |
|---------|------------|
| Calling `createPayee` (GraphQL) with a Bearer token | The `/dashboard/graphql` endpoint requires the `dashboard_session` cookie. Integrators should use the REST endpoints (Bearer token) instead. |
| Sending `sellerAccountId` / `legalEntityId` in a REST body | They are ignored — REST payees are always pinned to the seller account behind your Bearer token. |
| Forgetting `legalEntityId` on a GraphQL update | `PayeeInput.legalEntityId` is `Int!` — required even when not changing it. The REST `PATCH` has no such requirement. |
| Using an uppercase `role` on REST | REST accepts the lowercase values `contractor` and `tax`; anything else returns `400`. |
| Expecting REST `DELETE` to remove the payee | REST `DELETE` *archives* (status `Disabled`); the payee stays queryable and is restorable via `PATCH {"status":"active"}`. Only the GraphQL `deletePayee` hides the row (soft-delete). |

## See also

- **[GraphQL Authentication](https://docs.fynex.ai/payments-api/v2/docs#tag/graphql-auth)** — Obtain a dashboard_session cookie before calling any GraphQL mutation.
- **[Payout Methods](https://docs.fynex.ai/payments-api/v2/docs#tag/payout-methods)** — Register bank accounts as payout destinations for a payee.
- **[Payouts](https://docs.fynex.ai/payments-api/v2/docs#tag/payouts)** — Send money from a seller wallet to a payee
