# Payouts

> Manage payout methods, structures, assignments, accruals, and payments via the trackagoat API (v2).

All payout endpoints require a Bearer API key. Responses follow the standard `{ data, error, meta }` envelope. Amounts are in cents (integer). Timestamps are ISO 8601 UTC.

> **Warning: Write scope required for mutations**
>
> Every payout write requires the key to have the `write` scope. That covers `POST`, `PATCH`, and `DELETE` on `payout-methods`, `payout-structures` (including `[id]/assignments`), `payout-payments`, `payout-accruals`, and `payouts/payment-receipts` (and its `upload-url`). A read-only (`read`-only) key is rejected with **403 `insufficient_scope`**. `GET` endpoints need only the `read` scope.

---

## Payout Methods

Payout methods are org-scoped. Default methods (Venmo, Cash App, PayPal) cannot be deleted: use `PATCH { is_active: false }` to disable.

### GET /api/v2/payout-methods

List org's payout methods.

```bash
curl -H "Authorization: Bearer tga_<key>" \
  "https://www.trackagoat.com/api/v2/payout-methods"

# Include disabled methods
curl -H "Authorization: Bearer tga_<key>" \
  "https://www.trackagoat.com/api/v2/payout-methods?include_disabled=1"
```

**Query parameters**

| Parameter | Type | Description |
|-----------|------|-------------|
| `include_disabled` | `1` \| `true` | Include inactive methods (org admins only) |

**Response fields:** `id`, `org_id`, `name`, `icon_url`, `is_default`, `is_active`, `metadata`, `readme`, `created_at`

---

### POST /api/v2/payout-methods

Create a custom payout method.

```bash
curl -X POST \
  -H "Authorization: Bearer tga_<key>" \
  -H "Content-Type: application/json" \
  -d '{"name": "Zelle"}' \
  https://www.trackagoat.com/api/v2/payout-methods
```

**Body**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | ✓ | Display name (max 100 chars) |
| `icon_path` | string \| null | | Storage path of an uploaded icon. A new method's id isn't known until it's created, so add the icon afterwards: see [Method icons](#method-icons) |
| `metadata` | object | | Agent-writable structured data |
| `readme` | string \| null | | Markdown notes |

Returns `201` with the created method row.

---

### GET /api/v2/payout-methods/{id}

Fetch a single method.

---

### PATCH /api/v2/payout-methods/{id}

Update name, active state, metadata, or readme.

```bash
curl -X PATCH \
  -H "Authorization: Bearer tga_<key>" \
  -H "Content-Type: application/json" \
  -d '{"is_active": false}' \
  https://www.trackagoat.com/api/v2/payout-methods/<uuid>
```

**Body:** `name?`, `is_active?`, `icon_path?`, `metadata?`, `readme?` (at least one required). `icon_path` must be inside this method's folder (`orgs/{orgId}/{methodId}/…`), as returned by the icon upload URL below; `null` removes the icon.

---

### Method icons

Icons are PNG, JPEG or WebP images up to 1 MB, stored in a public bucket. Only org admins can change them (`403 forbidden` otherwise). Two ways to upload:

**Inline (one call).** `POST /api/v2/payout-methods/{id}/icon` with the image as base64. It is checked against `content_type` (`content_type_mismatch` otherwise), stored, and set as the method's `icon_path`; the updated method is returned.

```bash
curl -X POST \
  -H "Authorization: Bearer tga_<key>" \
  -H "Content-Type: application/json" \
  -d "{\"content_type\": \"image/png\", \"content_base64\": \"$(base64 < wise.png | tr -d '\n')\"}" \
  https://www.trackagoat.com/api/v2/payout-methods/<uuid>/icon
```

**Signed URL.** `POST /api/v2/payout-methods/{id}/icon/upload-url` with `{ "content_type": "image/png" }` returns `signed_url`, `path`, `max_bytes` and `expires_in_seconds` (2 hours). PUT the raw image to `signed_url` with the same `Content-Type`, then `PATCH /api/v2/payout-methods/{id}` with `{ "icon_path": "<path>" }`.

```bash
curl -X PUT -H "Content-Type: image/png" --data-binary @wise.png "<signed_url>"
```

---

### DELETE /api/v2/payout-methods/{id}

Delete a custom method. Returns `409 default_method` for seeded default methods.

---

## Payout Structures

Payout structures define earning rules for creators. Scoped to a project. Rule edits create new immutable versions.

### GET /api/v2/payout-structures

List structures in the org (optionally filtered to a project).

```bash
curl -H "Authorization: Bearer tga_<key>" \
  "https://www.trackagoat.com/api/v2/payout-structures?project_id=<uuid>"
```

**Query parameters**

| Parameter | Type | Description |
|-----------|------|-------------|
| `project_id` | uuid | Filter to a project |
| `is_active` | `true` \| `false` | Filter by active state |
| `limit` | number | Items per page (max 100, default 50) |
| `cursor` | string | Pagination cursor |

---

### POST /api/v2/payout-structures

Create a payout structure (creates version 1 atomically).

```bash
# Recurring $50/month
curl -X POST \
  -H "Authorization: Bearer tga_<key>" \
  -H "Content-Type: application/json" \
  -d '{
    "project_id": "<uuid>",
    "name": "Monthly base",
    "period": "monthly",
    "amount_cents": 5000,
    "currency": "USD",
    "criteria_type": "recurring",
    "criteria_params": {},
    "proration_mode": "skip"
  }' \
  https://www.trackagoat.com/api/v2/payout-structures

# Instagram-only recurring $50/month (platform scope)
curl -X POST \
  -H "Authorization: Bearer tga_<key>" \
  -H "Content-Type: application/json" \
  -d '{
    "project_id": "<uuid>",
    "name": "Instagram base",
    "period": "monthly",
    "amount_cents": 5000,
    "currency": "USD",
    "criteria_type": "recurring",
    "criteria_params": {},
    "proration_mode": "skip",
    "platform_scope": "platform",
    "platform": "instagram"
  }' \
  https://www.trackagoat.com/api/v2/payout-structures

# Goals-based monthly with partial completion
curl -X POST \
  -H "Authorization: Bearer tga_<key>" \
  -H "Content-Type: application/json" \
  -d '{
    "project_id": "<uuid>",
    "name": "Monthly goals bonus",
    "period": "monthly",
    "amount_cents": 50000,
    "currency": "USD",
    "criteria_type": "goals",
    "criteria_params": {
      "mode": "all",
      "partial_allowed": true,
      "partial_multiplier": 1.0,
      "min_completion_pct": 50
    },
    "proration_mode": "skip"
  }' \
  https://www.trackagoat.com/api/v2/payout-structures

# CPM-based weekly
curl -X POST \
  -H "Authorization: Bearer tga_<key>" \
  -H "Content-Type: application/json" \
  -d '{
    "project_id": "<uuid>",
    "name": "Weekly CPM",
    "period": "weekly",
    "amount_cents": 0,
    "currency": "USD",
    "criteria_type": "cpm",
    "criteria_params": {
      "source": "views_gained",
      "view_lifetime_days": null,
      "min_impressions": 10000,
      "max_payout_cents": 50000,
      "tiers": [
        {"up_to_impressions": 1000000, "cents_per_thousand": 50},
        {"up_to_impressions": null, "cents_per_thousand": 75}
      ]
    },
    "proration_mode": "skip"
  }' \
  https://www.trackagoat.com/api/v2/payout-structures

# View threshold: $20 at 1M views, $100 at 5M views
curl -X POST \
  -H "Authorization: Bearer tga_<key>" \
  -H "Content-Type: application/json" \
  -d '{
    "project_id": "<uuid>",
    "name": "Viral video milestones",
    "period": null,
    "amount_cents": null,
    "currency": "USD",
    "criteria_type": "view_threshold",
    "criteria_params": {
      "thresholds": [
        {"view_count": 1000000, "amount_cents": 2000, "currency": "USD"},
        {"view_count": 5000000, "amount_cents": 10000, "currency": "USD"}
      ]
    },
    "proration_mode": "skip",
    "backfill_on_assignment": false
  }' \
  https://www.trackagoat.com/api/v2/payout-structures
```

**Body**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `project_id` | uuid | ✓ | Target project |
| `name` | string | ✓ | Structure name |
| `period` | `daily` \| `weekly` \| `monthly` \| `null` | ✓ | Payout frequency. Pass `null` for `view_threshold` structures. |
| `amount_cents` | number \| `null` | ✓ | Amount in cents. Pass `null` for `view_threshold` and `conversion` structures (their rates live in `criteria_params`). Ignored for `cpm`: the tiers carry the money, so pass `0`. |
| `currency` | `USD` | ✓ | Currently only USD |
| `criteria_type` | `recurring` \| `goals` \| `cpm` \| `view_threshold` \| `conversion` | ✓ | Earning rule type |
| `criteria_params` | object | ✓ | Rule-specific params (see below) |
| `proration_mode` | `skip` \| `prorate` \| `full` | ✓ | Mid-period assignment behavior for `recurring` and `goals`. Ignored for `cpm` (earnings already reflect only the views posted in the period); `view_threshold` and `conversion` always use `skip`. |
| `backfill_on_assignment` | boolean | | `view_threshold` and `conversion` only. If `true`, also counts what the creator had already earned before assignment (milestones already crossed, or conversions already recorded). |
| `period_anchor` | 1-7 \| `null` | | `conversion` with a weekly cadence only. ISO day of week (1=Mon) the week starts on, so "every Tuesday" is expressible. `null` keeps the default Monday alignment. |
| `platform_scope` | `all` \| `platform` \| `account` | | Default `all`. Which of an assigned creator's platform accounts the rule earns against (see below). |
| `platform` | `tiktok` \| `instagram` \| `youtube` \| null | | Required when `platform_scope` is `platform`; omit/null otherwise. |
| `account_id` | uuid \| null | | Required when `platform_scope` is `account`; omit/null otherwise. |
| `metadata` | object | | Agent-writable data |
| `readme` | string \| null | | Markdown notes |

> **Note: Platform scope (Trackagoat 2.0)**
>
> Each structure version carries a platform scope, mirroring goals. `all` earns against the whole creator (every account), `platform` restricts earning to a single platform, and `account` restricts to one account. The scope is validated: `all` → no `platform`/`account_id`; `platform` → `platform` only; `account` → `account_id` only. A mismatch returns **400**. Scope is set per version: a rule-edit `PATCH` can change it, creating a new version.

**`criteria_params` shapes**

```typescript
// conversion: pay for recorded outcomes
criteria_params: {
  cadence: "every_conversion" | "daily" | "weekly" | "monthly",
  rates: [
    { conversion_event_id: uuid, rate_type: "flat",    amount_cents: number },
    { conversion_event_id: uuid, rate_type: "percent", percent_bps: number },  // 1000 = 10%
  ],
}
// `period` must equal `cadence`, or be null when cadence is "every_conversion".
// `amount_cents` is always null: the rates carry the money.
// Max 25 rates; each conversion event may appear at most once.

// recurring: no params
criteria_params: {}

// goals: pay if creator met their goals
criteria_params: {
  mode: "all" | "any" | "n_of_m",
  n?: number,               // required when mode = "n_of_m"

  // Partial completion (only valid when mode = "all")
  partial_allowed?: boolean,      // default false: enable fractional payouts on missed goals
  partial_multiplier?: number,    // default 1.0: scales the partial amount (0 to 1)
  min_completion_pct?: number,    // default 0: 0 to 100; below this avg completion%, no payout fires
}

// cpm: pay per 1,000 views. Each period pays for the views each video gained
// since it was last paid, so videos keep earning after their first period.
// The tiers carry the money: top-level `amount_cents` (pass 0) and
// `proration_mode` are both ignored for this type.
criteria_params: {
  source?: "views_gained",          // default; the retired "posts_in_period" is accepted and stored as "views_gained"
  view_lifetime_days?: number | null, // days after posting a video keeps earning (1 to 3650); null/omitted = forever
  min_impressions: number,          // views gained in a period before it pays; smaller periods carry over
  max_payout_cents: number | null,  // cap (null = unlimited)
  tiers: [                          // sorted ascending; last tier must have null up_to_impressions
    { up_to_impressions: number | null, cents_per_thousand: number }
  ]
}

// view_threshold: one-time bonus when a video crosses a view count
// period, amount_cents, and proration_mode must be null/skip for this type
criteria_params: {
  thresholds: [                  // 1 to 10 milestones; view_count values must be unique
    {
      view_count: number,        // minimum lifetime views on the video to trigger
      amount_cents: number,      // bonus amount in cents
      currency: "USD"
    }
  ]
}
```

**Partial completion formula (goals, mode = "all"):**

When `partial_allowed` is true and the creator does not fully meet all goals:
1. Each goal's completion fraction = `min(actual / target, 1.0)`.
2. Average those fractions → `completion_pct`.
3. If `completion_pct < min_completion_pct / 100` → no accrual.
4. Otherwise: `payout = amount × completion_pct × partial_multiplier`.

When all goals are fully met, the full amount is paid regardless of `partial_allowed`.

Returns `201` with `{ structure_id, version_id }`.

---

### GET /api/v2/payout-structures/{id}

Fetch structure detail including `current_version`, full `versions[]` history, and `assignment_count`.

---

### PATCH /api/v2/payout-structures/{id}

Update a structure. Use `kind` to distinguish cosmetic edits from rule changes.

```bash
# Cosmetic: update name and metadata without creating a new version
curl -X PATCH \
  -H "Authorization: Bearer tga_<key>" \
  -H "Content-Type: application/json" \
  -d '{"kind": "cosmetic", "name": "Monthly base v2", "metadata": {"owner": "growth-team"}}' \
  https://www.trackagoat.com/api/v2/payout-structures/<uuid>

# Rule edit: creates a new immutable version; existing accruals keep their version
curl -X PATCH \
  -H "Authorization: Bearer tga_<key>" \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "rule",
    "period": "monthly",
    "amount_cents": 7500,
    "currency": "USD",
    "criteria_type": "recurring",
    "criteria_params": {},
    "proration_mode": "skip"
  }' \
  https://www.trackagoat.com/api/v2/payout-structures/<uuid>
```

A rule PATCH accepts the same `platform_scope` / `platform` / `account_id` fields as create (same validation rules), letting a new version re-scope the payout to a platform or account. Omitting them keeps the current version's scope.

A rule PATCH replaces the whole rule, so send every field. To change only how long videos earn on a CPM structure, read the structure first and send its current `criteria_params` with `view_lifetime_days` changed (the MCP tool `set_cpm_view_lifetime` does this for you):

```bash
# Stop paying for a video's views 30 days after it's posted (null = forever)
curl -X PATCH \
  -H "Authorization: Bearer tga_<key>" \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "rule",
    "period": "weekly",
    "amount_cents": 0,
    "currency": "USD",
    "criteria_type": "cpm",
    "criteria_params": {
      "source": "views_gained",
      "view_lifetime_days": 30,
      "min_impressions": 10000,
      "max_payout_cents": 50000,
      "tiers": [
        {"up_to_impressions": 1000000, "cents_per_thousand": 50},
        {"up_to_impressions": null, "cents_per_thousand": 75}
      ]
    },
    "proration_mode": "skip"
  }' \
  https://www.trackagoat.com/api/v2/payout-structures/<uuid>
```

The new version applies from the period in progress; periods that already ended keep the version they were calculated with.

Rule PATCH returns `{ version_id, version_number }`. Cosmetic PATCH returns the updated structure row.

A rule PATCH cannot change `period` (or a conversion `cadence`) or `criteria_type` once the structure has any accruals or has ever had a creator assigned: it returns **409 `structure_shape_locked`**. Periods are generated from the structure's current shape, so changing it on a live structure would create overlapping accruals for periods that were already paid under the old shape. Create a new structure instead, assign creators to it, and archive the old one. Amounts, criteria parameters and proration can still be edited, and apply to future periods only.

---

### DELETE /api/v2/payout-structures/{id}

Archive (default) or hard-delete a structure.

```bash
# Archive: stops new accruals, preserves historical ones
curl -X DELETE -H "Authorization: Bearer tga_<key>" \
  "https://www.trackagoat.com/api/v2/payout-structures/<uuid>"

# Hard delete: blocked with 409 has_accruals if accruals exist
curl -X DELETE -H "Authorization: Bearer tga_<key>" \
  "https://www.trackagoat.com/api/v2/payout-structures/<uuid>?mode=hard"
```

---

## Assignments

Assign or unassign creators to a payout structure.

### GET /api/v2/payout-structures/{id}/assignments

List currently assigned creators.

```bash
curl -H "Authorization: Bearer tga_<key>" \
  "https://www.trackagoat.com/api/v2/payout-structures/<uuid>/assignments"
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `include_inactive` | `1` \| `true` | Include historically unassigned creators |
| `limit` | number | Default 50, max 100 |
| `cursor` | string | Pagination cursor |

---

### POST /api/v2/payout-structures/{id}/assignments

Bulk-assign creators. Returns counts of newly assigned, already-assigned, and not-in-project IDs.

```bash
curl -X POST \
  -H "Authorization: Bearer tga_<key>" \
  -H "Content-Type: application/json" \
  -d '{"creator_ids": ["<uuid1>", "<uuid2>"]}' \
  https://www.trackagoat.com/api/v2/payout-structures/<uuid>/assignments
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `creator_ids` | uuid[] | ✓ | Creators to assign |
| `effective_at` | ISO 8601 datetime | | When earning begins. Defaults to now. Pass a future period boundary to start on a later period. Cannot be earlier than the start of the period currently in progress (400); earning cannot be backdated into periods that have already closed. |
| `first_period_mode` | `prorate` \| `full` | | How the period containing `effective_at` is treated. Omitted inherits the structure's `proration_mode`. Ignored when `effective_at` is already a period boundary, since the period is whole either way. |

`first_period_mode` means different things by criteria type. For `recurring` and `goals` it selects how the amount is prorated. For `cpm` there is no amount to prorate, so it moves the start of the window instead: `prorate` counts only videos posted, and views gained, at or after `effective_at`; `full` counts every video posted since the period started. That start holds for every later period.

```bash
# Start on next Monday, taking that period whole
curl -X POST \
  -H "Authorization: Bearer tga_<key>" \
  -H "Content-Type: application/json" \
  -d '{
    "creator_ids": ["<uuid1>"],
    "effective_at": "2026-08-24T05:00:00Z",
    "first_period_mode": "full"
  }' \
  https://www.trackagoat.com/api/v2/payout-structures/<uuid>/assignments
```

Returns `{ assigned, already_assigned, not_in_project, results }`.

---

### DELETE /api/v2/payout-structures/{id}/assignments

Bulk-unassign creators. Existing earned accruals remain payable.

```bash
curl -X DELETE \
  -H "Authorization: Bearer tga_<key>" \
  -H "Content-Type: application/json" \
  -d '{"creator_ids": ["<uuid1>"]}' \
  https://www.trackagoat.com/api/v2/payout-structures/<uuid>/assignments
```

---

## Payout Accruals

Accruals are computed by an hourly background job: they cannot be created via the API.

### GET /api/v2/payout-accruals

List accruals with optional filters.

```bash
curl -H "Authorization: Bearer tga_<key>" \
  "https://www.trackagoat.com/api/v2/payout-accruals?project_id=<uuid>&status=earned"
```

**Query parameters**

| Parameter | Type | Description |
|-----------|------|-------------|
| `project_id` | uuid | Filter to a project |
| `creator_id` | uuid | Filter to a creator |
| `structure_id` | uuid | Filter to a structure |
| `status` | `earned` \| `paid` \| `rejected` \| `voided` | Filter by status |
| `from` | YYYY-MM-DD | Period start on or after this date |
| `to` | YYYY-MM-DD | Period end on or before this date |
| `limit` | number | Default 50, max 100 |
| `cursor` | string | Pagination cursor (`period_end_at` ISO) |

---

### GET /api/v2/payout-accruals/{id}

Fetch a single accrual including `inputs_snapshot`.

For a CPM accrual, `inputs_snapshot.video_gains` lists each video that earned in the period as `{ video_id, views_start, views_end, gained }`: the view count it was already paid up to, the count this accrual paid up to, and the difference. The next period pays from `views_end`. Rejecting or voiding a CPM accrual does not hand its views to a later period. Accruals calculated before 2026-10-09 have `source: "posts_in_period"` and no `video_gains`.

---

### PATCH /api/v2/payout-accruals/{id}

Manually reject or void an earned accrual.

```bash
curl -X PATCH \
  -H "Authorization: Bearer tga_<key>" \
  -H "Content-Type: application/json" \
  -d '{"status": "rejected"}' \
  https://www.trackagoat.com/api/v2/payout-accruals/<uuid>
```

**Body:** `{ status: "rejected" | "voided" }`

Returns `409 cannot_set_status_on_paid_accrual` if the accrual is already paid: void the parent payment first.

---

## Payout Payments

### GET /api/v2/payout-payments

List payments with optional filters.

```bash
curl -H "Authorization: Bearer tga_<key>" \
  "https://www.trackagoat.com/api/v2/payout-payments?project_id=<uuid>&status=paid"
```

**Query parameters**

| Parameter | Type | Description |
|-----------|------|-------------|
| `project_id` | uuid | Filter to a project |
| `creator_id` | uuid | Filter to a creator |
| `status` | `paid` \| `rejected` \| `voided` | Filter by status |
| `from` | YYYY-MM-DD | Paid at or after this date |
| `to` | YYYY-MM-DD | Paid at or before this date |
| `limit` | number | Default 50, max 100 |
| `cursor` | string | Pagination cursor (`paid_at` ISO) |

Response rows include `payout_payment_lines[]` (linked accruals) and `payout_adjustments[]` (over/underpayment lines).

---

> **Warning: Idempotency key required**
>
> `POST /api/v2/payout-payments` **requires** an `Idempotency-Key` header (a UUID, max 128 characters). A request without one is rejected with **400 `idempotency_key_required`**. If a request times out, retry with the same key: the server returns the original payment (**200**, `meta.idempotent_replay: true`, `Idempotency-Replay: true` header) and never records it twice. Keys are permanent per organization, so generate a fresh one for each new payment.

### POST /api/v2/payout-payments

Record a payment. Requires an `Idempotency-Key` header.

```bash
# Settle all outstanding accruals for a creator
curl -X POST \
  -H "Authorization: Bearer tga_<key>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "project_id": "<uuid>",
    "creator_id": "<uuid>",
    "method_id": "<uuid>",
    "total_amount_cents": 15000,
    "currency": "USD",
    "auto_apply_outstanding": true,
    "note": "April batch payment"
  }' \
  https://www.trackagoat.com/api/v2/payout-payments

# Ad-hoc payment with no linked accruals
curl -X POST \
  -H "Authorization: Bearer tga_<key>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "project_id": "<uuid>",
    "creator_id": "<uuid>",
    "total_amount_cents": 5000,
    "note": "Bonus for viral video"
  }' \
  https://www.trackagoat.com/api/v2/payout-payments
```

**Body**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `project_id` | uuid | ✓ | Target project |
| `creator_id` | uuid \| null | | Creator receiving payment (null for true ad-hoc). Required when `accrual_ids` or `auto_apply_outstanding` is set |
| `method_id` | uuid \| null | | Payout method used |
| `total_amount_cents` | number | ✓ | Total paid in cents (≥ 0) |
| `currency` | string | | ISO 4217, default `USD` |
| `tracking_url` | string \| null | | External receipt link |
| `note` | string \| null | | Free-form note |
| `paid_at` | ISO 8601 | | When the payment was sent. Defaults to now; may be backdated to record a historical payment, but cannot be in the future |
| `accrual_ids` | uuid[] | | Explicit accruals to settle (takes precedence over `auto_apply_outstanding`). Every accrual must belong to `creator_id`, otherwise **409 `accrual_creator_mismatch`** |
| `supplemental_ids` | uuid[] | | Owed [supplemental amounts](#supplemental-amounts) to settle with this payment. They count toward the amount the payment applies, like accruals. Each must belong to `creator_id` (**409 `supplemental_creator_mismatch`**) and be `owed` in this project and currency (**409 `supplemental_not_settleable`**) |
| `auto_apply_outstanding` | boolean | | Settles all of the creator's `earned` accruals and `owed` supplemental amounts in this project and `currency` (when `accrual_ids` and `supplemental_ids` are empty). Requires `creator_id`. Returns **409 `nothing_outstanding`** and records nothing when there are none |
| `adjustment_reason` | string \| null | | Required when total differs from accrual sum |
| `metadata` | object | | Agent-writable data |
| `readme` | string \| null | | Markdown notes |
| `payment_id` | uuid | | Your own id for the payment. Required with `attachment_path`, because the receipt is uploaded under it before the payment exists. Returns **409 `payment_id_in_use`** if another payment has it |
| `attachment_path` | string | | A receipt's `path` from [Payment receipts](#payment-receipts). Must be in this `payment_id`'s folder, otherwise **400 `invalid_attachment_path`** |

Returns `201` with the payment row, or `200` with the original payment when the `Idempotency-Key` was already used.

---

### GET /api/v2/payout-payments/{id}

Fetch a payment including lines, adjustments and the supplemental amounts it settled (`payout_supplemental_amounts`).

---

### Send a payout through a connected provider

`POST /api/v2/payout-payments/send` pays a creator through the organization's connected provider ([Dots](https://www.trackagoat.com/docs/guides/integrated-payouts.md)) and records the payment. `POST /api/v2/payout-payments` only records a payment made somewhere else; this endpoint moves money.

It works only when all of these hold, otherwise nothing is sent:

- the key has the `write` scope and acts as an **org admin** (`403 insufficient_role`);
- an org admin turned on **Agent payments** in Org settings → Payment providers (`403 agent_payments_disabled`);
- the amount fits the org's agent limits (`403 agent_cap_exceeded`, with `meta.cap` = `per_payment` or `daily` and `meta.limit_cents`);
- the creator is linked to a payee (`409 payee_not_linked`; check with [`GET /creators/{id}/payee-links`](#get-apiv2creatorsidpayee-links));
- an `Idempotency-Key` header is sent. Reusing it returns the original payment and never pays twice.

```bash
curl -X POST \
  -H "Authorization: Bearer tga_<key>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "project_id": "<uuid>",
    "creator_id": "<uuid>",
    "auto_apply_outstanding": true,
    "provider_options": { "memo": "September CPM payout" }
  }' \
  https://www.trackagoat.com/api/v2/payout-payments/send
```

**Body**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `project_id` | uuid | ✓ | Project the accruals belong to |
| `creator_id` | uuid | ✓ | Creator to pay |
| `provider` | string | | `dots` (default) |
| `accrual_ids` | uuid[] | | Earned accruals to pay |
| `supplemental_ids` | uuid[] | | Owed supplemental amounts to pay with them |
| `auto_apply_outstanding` | boolean | | Pay everything the creator is owed in this project and currency: earned accruals and owed supplemental amounts. `409 nothing_outstanding` if there is nothing |
| `total_amount_cents` | integer | | Amount to send (≥ 1). Defaults to the total of what is being paid |
| `adjustment_reason` | string | | Required when `total_amount_cents` differs from the accruals' total |
| `currency` | string | | `USD` (Dots pays USD only) |
| `note` | string | | Internal note on the trackagoat payment |
| `provider_options` | object | | Provider options, below |

**Dots `provider_options`**

| Field | Type | Description |
|-------|------|-------------|
| `memo` | string (≤ 500) | Note shown to the creator with the payout |
| `message` | string (≤ 300) | Text of the SMS Dots sends with a claim link (creators linked by phone) |
| `description` | string (≤ 500) | Description saved on the payout in Dots |
| `tax_exempt` | boolean | Leave this payout out of the creator's 1099 |

Unknown fields are rejected with `400 invalid_provider_options`.

**Response.** `201` with the payment row. Provider fields:

| Field | Description |
|-------|-------------|
| `provider` | `dots` |
| `provider_status` | `completed`, `awaiting_claim` (creator was texted a claim link), `processing`, `unconfirmed` (Dots didn't answer in time; trackagoat keeps checking: don't send again with a new key), `failed` or `canceled` |
| `provider_payment_id` | Dots payout link id |
| `provider_error_code`, `provider_error_message` | Why a payout failed, or a note such as "Waiting for funds in your Dots balance" |
| `provider_options` | The options sent |
| `initiated_via` | `ui`, `api` or `mcp` |

When Dots refuses the payout, the response is `422 provider_send_failed` with the payment in `data`: it is already voided, so its accruals are owed again, and `provider_error_code` says why (for example `transaction-insufficient-funds`). Retrying needs a new `Idempotency-Key`.

`failed` and `canceled` payments are always voided. Read the status later with `GET /api/v2/payout-payments/{id}`. A payment that is `awaiting_claim` or still in flight can't be voided (`409 provider_cancel_required` / `409 provider_in_flight`); cancel an unclaimed payout from the creator's Payouts tab.

---

### GET /api/v2/creators/{id}/payee-links

Read scope and an org admin (`403 insufficient_role` otherwise), like the creator's payout account in the app. Whether a creator can be paid through each connected provider, and who they are paid as (phone masked). `meta` carries the org's agent-payment switch and limits.

```json
{
  "data": [
    {
      "provider": "dots",
      "provider_name": "Dots",
      "environment": "production",
      "connection_status": "active",
      "linked": true,
      "payee": { "provider_payee_id": "a1b2…", "name": "Sarah Brown", "phone": "+1 ••• 4567", "source": "claimed", "linked_at": "2026-10-09T18:22:04Z" }
    }
  ],
  "meta": { "agent_payments_enabled": true, "agent_payment_max_cents": 50000, "agent_payment_daily_max_cents": 200000 }
}
```

---

### PATCH /api/v2/payout-payments/{id}

Void a payment or update metadata/readme.

```bash
# Void: reverts linked accruals to "earned" and settled supplemental amounts to "owed"
curl -X PATCH \
  -H "Authorization: Bearer tga_<key>" \
  -H "Content-Type: application/json" \
  -d '{"status": "voided"}' \
  https://www.trackagoat.com/api/v2/payout-payments/<uuid>
```

**Body:** `{ status?: "voided", metadata?: object, readme?: string | null }` (at least one field required). A receipt can only be attached when the payment is recorded, as in the app.

---

## Payouts tracker

### GET /api/v2/payouts/tracker

Per-creator balances for a project, as on the app's Payouts page. Query: `project_id` (required), `creator_ids` (comma-separated), `structure_id`, `campaign_id`, `from`, `to` (YYYY-MM-DD, org time zone), `outstanding_only` (default `false`), `include_removed` (default `false`), `limit` (1 to 200, default 50), `cursor` (`meta.nextCursor`). Largest outstanding first.

Each row has `total_earned_cents`, `total_paid_cents`, `adjustment_cents` (signed: the part of paid that settled no accrual), `adjustment_breakdown` (`[{ reason, amount_cents, count }]`, largest first), `outstanding_cents`, `last_paid_at`, `is_removed` and counts. `meta.totals` has `earned_cents`, `paid_cents`, `adjustment_cents`, `adjustment_breakdown`, `outstanding_cents`, `creator_count_outstanding` and the removed-creator summary (`removed_creator_count_outstanding`, `removed_outstanding_cents`). Without `from`/`to`, every row and the totals satisfy `earned − paid + adjustments = outstanding`. MCP: `get_payouts_tracker`.

---

## Supplemental amounts

A supplemental amount is an owed correction on top of a **paid** accrual, for example posts found after the period was paid. The accrual and its payment are never changed. The amount is `owed`, counts in the creator's outstanding balance, and is settled by [recording a payment](#post-apiv2payout-payments) (`supplemental_ids` or `auto_apply_outstanding`). MCP: `list_payout_supplementals`, `create_payout_supplemental`, `void_payout_supplemental`.

### GET /api/v2/payout-supplementals

Query: `project_id`, `creator_id`, `structure_id`, `accrual_id`, `status` (`owed` \| `paid` \| `void`), `cursor`, `limit` (1 to 100, default 50). Newest first.

### POST /api/v2/payout-supplementals

Requires the `write` scope. Send an `Idempotency-Key` header: a retry with the same key returns the original row.

```bash
curl -X POST \
  -H "Authorization: Bearer tga_<key>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 6f1d0c1e-…" \
  -d '{"project_id": "<uuid>", "accrual_id": "<paid accrual uuid>", "amount_cents": 1333, "reason": "Posts found after the week was paid"}' \
  https://www.trackagoat.com/api/v2/payout-supplementals
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `project_id` | uuid | ✓ | Project |
| `accrual_id` | uuid | | The **paid** accrual this corrects; its creator, structure and period are used. An unpaid accrual returns **409 `accrual_not_paid`** |
| `creator_id`, `structure_id`, `period_start_at`, `period_end_at` | | | Instead of `accrual_id`: all four are required |
| `amount_cents` | integer | ✓ | Amount owed, ≥ 1 |
| `currency` | string | | Default `USD`; must match the accrual |
| `reason` | string | ✓ | 1 to 500 characters; shown as "Supplemental: &lt;reason&gt;" |

Returns `201` with the row (`status: "owed"`). A removed creator returns **404 `not_found`**: restore the creator first.

### GET /api/v2/payout-supplementals/{id}

Fetch one supplemental amount.

### PATCH /api/v2/payout-supplementals/{id}

Void an owed amount: `{ "status": "void", "reason"?: string }`. A paid one returns **409 `supplemental_paid`**: void the payment that settled it instead (it becomes owed again).

---

### Payment receipts

A receipt is a file attached to a payment: PNG, JPEG, WebP, PDF, CSV, Word or Excel, up to 5 MB, stored privately. Generate a `payment_id` UUID first, upload the receipt under it, then record the payment with the same `payment_id` and `attachment_path`.

**Inline, up to 3 MB.** `POST /api/v2/payouts/payment-receipts`:

```bash
PAYMENT_ID=$(uuidgen | tr 'A-Z' 'a-z')
curl -X POST \
  -H "Authorization: Bearer tga_<key>" \
  -H "Content-Type: application/json" \
  -d "{\"payment_id\": \"$PAYMENT_ID\", \"project_id\": \"<project>\", \"filename\": \"receipt.pdf\", \"content_type\": \"application/pdf\", \"content_base64\": \"$(base64 < receipt.pdf | tr -d '\n')\"}" \
  https://www.trackagoat.com/api/v2/payouts/payment-receipts
```

Returns `201` with `{ path, content_type, size_bytes }`. The file's contents must match `content_type` (`content_type_mismatch`); larger files get `413 file_too_large`.

**Signed URL, up to 5 MB.** `POST /api/v2/payouts/payment-receipts/upload-url` with `{ payment_id, project_id, filename, content_type }` returns `signed_url`, `path`, `max_bytes` and `expires_in_seconds` (2 hours). PUT the raw file to `signed_url` with the same `Content-Type`:

```bash
curl -X PUT -H "Content-Type: application/pdf" --data-binary @receipt.pdf "<signed_url>"
```

Then record the payment with `"payment_id": "$PAYMENT_ID"` and `"attachment_path": "<path>"`.

---

## Error codes

| Code | HTTP | Description |
|------|------|-------------|
| `invalid_api_key` | 401 | Missing, expired, or invalid Bearer token |
| `insufficient_scope` | 403 | Write attempted with a key that lacks the `write` scope |
| `rate_limit_exceeded` | 429 | Daily API quota or per-minute (60 req/min) rate limit hit |
| `limit_reached` | 402 | Org plan limit for methods or structures |
| `invalid_body` | 400 | Zod validation failed: see `meta.errors` |
| `invalid_query` | 400 | Bad query parameter |
| `default_method` | 409 | Cannot delete a seeded default method |
| `conflict_name` | 409 | Method name already exists in org |
| `structure_archived` | 409 | Cannot assign creators to an archived structure |
| `has_accruals` | 409 | Cannot hard-delete a structure with existing accruals |
| `cannot_set_paid_directly` | 400 | Use POST /payout-payments instead |
| `cannot_set_status_on_paid_accrual` | 409 | Void the parent payment first |
| `currency_mismatch` | 400 | Payment currency doesn't match accrual currency |
| `accrual_not_settleable` | 409 | Accrual is wrong project, status, or currency |
| `accrual_creator_mismatch` | 409 | An accrual belongs to a different creator than the payment's `creator_id` |
| `nothing_outstanding` | 409 | `auto_apply_outstanding` found no earned accruals; nothing was recorded |
| `idempotency_key_required` | 400 | `POST /payout-payments` was sent without an `Idempotency-Key` header |
| `idempotency_conflict` | 409 | `Idempotency-Key` already used for a payment in another project |
| `structure_shape_locked` | 409 | Rule edit changes `period` or `criteria_type` on a structure that has accruals or assignments: create a new structure instead |
| `adjustment_reason_required` | 400 | Total differs from accrual sum: provide `adjustment_reason` |
| `already_voided` | 400 | Payment is already voided |
| `accrual_in_other_payment` | 409 | Accrual has been re-paid: void that payment first |
| `payment_id_in_use` | 409 | `payment_id` already belongs to another payment |
| `invalid_attachment_path` | 400 | `attachment_path` isn't a receipt uploaded for this `payment_id` |
| `invalid_mime_type` | 400 | Upload type not allowed (see `meta.allowed`) |
| `content_type_mismatch` | 400 | Uploaded file's contents don't match `content_type` |
| `invalid_base64` | 400 | `content_base64` isn't valid base64 |
| `file_too_large` | 413 | Inline upload over 3 MB (receipts) or 1 MB (icons) |
| `agent_payments_disabled` | 403 | An org admin hasn't turned on agent payments |
| `agent_cap_exceeded` | 403 | The payout is over the org's per-payout or 24-hour agent limit |
| `insufficient_role` | 403 | The key acts as a member who isn't an org admin: payout setup, recording or sending payments, method icons and payout accounts need one |
| `provider_not_connected` | 409 | No payment provider is connected |
| `provider_connection_error` | 409 | The provider rejected the stored keys; an admin must update them |
| `payee_not_linked` | 409 | The creator isn't linked to a payee |
| `invalid_provider_options` | 400 | `provider_options` has an unknown or too-long field |
| `provider_send_failed` | 422 | The provider refused the payout; the payment was voided |
| `provider_cancel_required` | 409 | The payout is awaiting claim: cancel it instead of voiding |
| `provider_in_flight` | 409 | The provider hasn't settled the payout yet |
| `not_found` | 404 | Resource not found or not in org |

---
Source: https://www.trackagoat.com/docs/api-reference/payouts
