{
  "openapi": "3.0.3",
  "info": {
    "title": "Affset API",
    "version": "1.0.0",
    "description": "REST API of Affset, the white-label ad server and CPA network platform. Manage campaigns, zones, targeting, payouts, team members and tenant settings, and read stats and conversions. The public ad-serving endpoints (`/serve`, `/track/click`, `/px`) are the tracking surface you hand to traffic sources and advertisers; the public sign-in endpoints under `/api/public/auth/` back the passwordless dashboard login.\n\n## Authentication\n\nEvery authenticated endpoint takes two headers:\n\n- `Authorization: Bearer <token>` — a tenant API key, or the 30-day session token issued by magic-link login. Both authenticate identically.\n- `X-Namespace: <namespace>` — the tenant the token belongs to.\n\nKeys carry `permissions`: `read` covers GET, `write` is required for every POST, PUT, PATCH and DELETE (each operation's `x-affset-permission` says which). Within the tenant, what a key can see and change is decided by its role:\n\n| Role | Access |\n| --- | --- |\n| `owner` | Full access to everything. The only role that can permanently delete the tenant. |\n| `manager` | Same day-to-day access as owner — campaigns, zones, team, payouts, targeting. Can’t delete the tenant. |\n| `advertiser` | Manages their own campaigns and can read zones. Sees campaign spend, but not publisher payout, media cost, or ROI. |\n| `advertiser_manager` | Manages campaigns for assigned advertisers and can add advertisers to their own team. Uses the same financial redaction as advertiser. |\n| `publisher` | Manages their own zones and has no campaign access. Sees payout, media cost, and ROI, but not advertiser spend. |\n| `publisher_manager` | Manages zones for assigned publishers and can add publishers to their own team. Uses the same financial redaction as publisher. |\n\n`GET /api/me` reports the role, permissions and a capabilities summary for the token in use.\n\n- `read` — GET endpoints — list and read resources, stats, and conversions.\n- `write` — POST, PUT, PATCH and DELETE — create, update, delete, rotate, revoke.\n\nThere is no OAuth flow on this REST API itself — tokens are API keys. The hosted MCP server at https://mcp.affset.com/mcp is the OAuth-protected surface over the same API; its scopes map onto the key permissions above and are declared machine-readably in its RFC 9728 metadata (https://mcp.affset.com/.well-known/oauth-protected-resource) and RFC 8414 metadata (https://oauth.affset.com/.well-known/oauth-authorization-server):\n\n- scope `read` → key permissions `read`. Read-only tool set — every tool that writes is stripped from the session.\n- scope `full` → key permissions `read`, `write`. Every tool, backed by a read+write key. Only offered to roles that can write.\n\n## Errors\n\nErrors are JSON: `{ \"error\": \"human-readable message\" }`, sometimes with extra machine-readable fields (see the `PlanLimitError` schema for 402).\n\n- **400** Bad Request — missing header, parameter, or body, or an invalid value.\n- **401** Unauthorized — invalid or expired key, or X-Namespace doesn't match it.\n- **402** Payment Required — you’re at a plan limit. See below.\n- **403** Forbidden — your role or permissions don't allow this. Also used to mask ownership on writes.\n- **404** Not Found — the resource doesn't exist, or isn't visible to your role.\n- **409** Conflict — something with the same identity already exists.\n- **422** Unprocessable Entity — the request is well-formed but cannot be processed (e.g. a CSV export that exceeds the row cap).\n- **500** Internal Server Error — something went wrong on our end.\n\n## Other formats\n\n- Human reference: https://affset.com/docs (operation `x-affset-docs` links point at the matching section)\n- Markdown feed for assistants: https://affset.com/api-reference.md\n- Structured feed: https://affset.com/api-reference.json\n- Site index for crawlers and agents: https://affset.com/llms.txt\n- MCP server (typed tools over this API): https://mcp.affset.com/mcp — source https://github.com/affset/mcp",
    "termsOfService": "https://affset.com/terms",
    "contact": {
      "name": "Affset",
      "url": "https://affset.com/contact"
    }
  },
  "externalDocs": {
    "description": "Affset API reference",
    "url": "https://affset.com/docs"
  },
  "servers": [
    {
      "url": "https://api.affset.com",
      "description": "Production. Tenants with a custom API domain (PUT /api/tenant custom_api_domain) also serve the public ad-serving endpoints from that domain."
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "tags": [
    {
      "name": "Auth",
      "description": "Verify a token and see what it can do."
    },
    {
      "name": "Tenant settings",
      "description": "Branding, timezone, sub-label names, and a couple of serving behaviors for your account."
    },
    {
      "name": "Stats",
      "description": "Aggregated performance, grouped by one dimension per call."
    },
    {
      "name": "Campaigns",
      "description": "Owner and manager see every campaign. advertiser and advertiser_manager are scoped to their own or managed advertisers. publisher and publisher_manager get 403 on this entire tree."
    },
    {
      "name": "Zones",
      "description": "Owner, manager, publisher and publisher_manager can create/update zones — publisher and publisher_manager scoped to their own or managed publishers. advertiser and advertiser_manager have read-only access."
    },
    {
      "name": "Team",
      "description": "Team members and machine API keys share one endpoint, distinguished by type. There’s no separate /api/team."
    },
    {
      "name": "Payout rules",
      "description": "Same campaign-scoping as Campaigns above. A campaign can have one global rule and one rule per zone."
    },
    {
      "name": "Traffic Sources",
      "description": "Owner, manager, advertiser and advertiser_manager can manage traffic sources — same scoping as Campaigns above. publisher and publisher_manager get 403 on this entire tree. The stored api_token is write-only: every read returns has_api_token instead. Zones link to a source via traffic_source_id on POST/PUT /api/zones (see Zones above); zone reads then include the joined traffic_source_name. Sources with an api_token and an exoclick, trafficstars or richads preset get cost sync: an hourly job pulls the network’s own daily spend (re-syncing the last three days), and the unfiltered group_by=date Statistics view prefers those numbers for complete days and the current day-to-date over the cost= click-time estimate — link your zones to the source so the estimate is replaced rather than added on top. The same token powers blacklist push (blocked placements pushed into the network account, one outcome per network campaign) and bids (campaign bids read live from the network and changed in one call, every write recorded)."
    },
    {
      "name": "Offers",
      "description": "The CPA-network catalog. Owner/manager and advertiser-side roles (advertiser, advertiser_manager) manage offers — same ownership rule as Campaigns above. Publisher-side roles (publisher, publisher_manager) get a stripped, payout-only catalog instead: active public/apply offers, plus any private offer they already hold a link for."
    },
    {
      "name": "Payouts",
      "description": "Money out for the network: publisher balance, ledger history, and payout requests. Publishers act only for themselves; publisher_manager acts for a managed publisher (user_email required); owner/manager act for any publisher (user_email required) and are the only roles that can decide requests or post manual adjustments. Advertiser-side roles get 403 on this entire tree — affiliate earnings are the network’s ledger, never the advertiser’s. Amounts are integer cents throughout."
    },
    {
      "name": "Targeting",
      "description": "Same campaign-scoping as Campaigns above. Enforced on /serve only — never on a direct /track/click link."
    },
    {
      "name": "Conversions",
      "description": "The conversion audit trail — individual records, not aggregates."
    },
    {
      "name": "Sign-in",
      "description": "Public — no Authorization or X-Namespace header. The passwordless dashboard sign-in behind {namespace}.affset.com/login and the generic app.affset.com/login. Every response uses Cache-Control: no-store. In user-facing copy a namespace is called a workspace; the API keeps namespace."
    },
    {
      "name": "Ad serving",
      "description": "Public — no Authorization or X-Namespace header. Namespace is resolved from the zone/campaign in the URL. On error these return plain text, not JSON."
    }
  ],
  "paths": {
    "/api/me": {
      "get": {
        "operationId": "me",
        "summary": "Verify a key and see what it can do",
        "description": "- The response also includes capabilities, a role-derived summary of what this key can do — handy for building conditional UI without hard-coding the role table above.\n\nDocs: https://affset.com/docs#me",
        "tags": [
          "Auth"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "namespace": {
                      "type": "string"
                    },
                    "user_id": {
                      "type": "string"
                    },
                    "email": {
                      "type": "string",
                      "format": "email"
                    },
                    "role": {
                      "type": "string"
                    },
                    "permissions": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    },
                    "capabilities": {
                      "type": "object",
                      "properties": {
                        "campaigns": {
                          "type": "boolean"
                        },
                        "zones": {
                          "type": "boolean"
                        },
                        "assign_campaign_user": {
                          "type": "boolean"
                        },
                        "assign_zone_user": {
                          "type": "boolean"
                        },
                        "tenant_management": {
                          "type": "boolean"
                        }
                      }
                    }
                  }
                },
                "example": {
                  "namespace": "acme-media",
                  "user_id": "usr_8f2a1c",
                  "email": "buyer@example.com",
                  "role": "advertiser",
                  "permissions": [
                    "read",
                    "write"
                  ],
                  "capabilities": {
                    "campaigns": true,
                    "zones": true,
                    "assign_campaign_user": false,
                    "assign_zone_user": false,
                    "tenant_management": false
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#me"
        ],
        "x-affset-permission": "read"
      }
    },
    "/api/tenant": {
      "get": {
        "operationId": "tenantGet",
        "summary": "Read tenant settings",
        "description": "- There’s also an unauthenticated GET /api/public/tenant (X-Namespace header only, no API key) that returns just company, primary_color and secondary_color — enough to brand a public-facing page.\n\nDocs: https://affset.com/docs#tenant-get",
        "tags": [
          "Tenant settings"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "company": {
                      "type": "string"
                    },
                    "timezone": {
                      "type": "string"
                    },
                    "primary_color": {
                      "type": "string"
                    },
                    "secondary_color": {
                      "type": "string"
                    },
                    "custom_api_domain": {
                      "type": "string"
                    },
                    "sub_labels": {
                      "type": "object",
                      "properties": {
                        "sub1": {
                          "type": "string"
                        },
                        "sub2": {
                          "type": "string"
                        }
                      }
                    },
                    "redirect_method": {
                      "type": "string"
                    },
                    "email": {
                      "type": "string",
                      "format": "email"
                    },
                    "feature_flags": {
                      "type": "object",
                      "properties": {}
                    }
                  }
                },
                "example": {
                  "company": "Acme Media",
                  "timezone": "America/New_York",
                  "primary_color": "#4F46E5",
                  "secondary_color": "#14161C",
                  "custom_api_domain": "api.acme-media.com",
                  "sub_labels": {
                    "sub1": "Zone",
                    "sub2": "Creative"
                  },
                  "redirect_method": "html",
                  "email": "owner@acme-media.com",
                  "feature_flags": {}
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#tenant-get"
        ],
        "x-affset-permission": "read"
      },
      "put": {
        "operationId": "tenantUpdate",
        "summary": "Update tenant settings",
        "description": "- At least one field is required.\n- sub_labels is a merge, not a replace — send only the keys you want to change. null or \"\" clears that slot. Max 40 characters per label; an unrecognized key like sub6 is a 400, not a silent no-op.\n- Setting a non-empty custom_api_domain is plan-gated and can return 402; clearing it is always free.\n\nDocs: https://affset.com/docs#tenant-update",
        "tags": [
          "Tenant settings"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "company": {
                      "type": "string"
                    },
                    "timezone": {
                      "type": "string"
                    },
                    "redirect_method": {
                      "type": "string"
                    },
                    "sub_labels": {
                      "type": "object",
                      "properties": {
                        "sub1": {
                          "type": "string"
                        }
                      }
                    },
                    "updated_at": {
                      "type": "integer"
                    }
                  }
                },
                "example": {
                  "company": "Acme Media",
                  "timezone": "America/New_York",
                  "redirect_method": "3xx",
                  "sub_labels": {
                    "sub1": "Zone"
                  },
                  "updated_at": 1753887600000
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "402": {
            "$ref": "#/components/responses/Error402"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#tenant-update"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "company": {
                    "type": "string",
                    "description": "Display name shown in the dashboard and emails."
                  },
                  "timezone": {
                    "type": "string",
                    "description": "IANA timezone, e.g. \"America/New_York\". Drives Stats date bucketing and campaign date-only schedules."
                  },
                  "primary_color": {
                    "type": "string",
                    "description": "Hex color, e.g. \"#4F46E5\". Must match #RRGGBB."
                  },
                  "secondary_color": {
                    "type": "string",
                    "description": "Hex color, e.g. \"#14161C\". Must match #RRGGBB."
                  },
                  "custom_api_domain": {
                    "type": "string",
                    "description": "Domain used in generated /serve and /track/click links instead of the default API host. Setting a non-empty value is plan-gated; clearing it is always free."
                  },
                  "sub_labels": {
                    "type": "object",
                    "properties": {
                      "sub1": {
                        "type": "string",
                        "nullable": true,
                        "maxLength": 40,
                        "description": "Display name for sub1; null or \"\" clears it."
                      },
                      "sub2": {
                        "type": "string",
                        "nullable": true,
                        "maxLength": 40,
                        "description": "Display name for sub2; null or \"\" clears it."
                      },
                      "sub3": {
                        "type": "string",
                        "nullable": true,
                        "maxLength": 40,
                        "description": "Display name for sub3; null or \"\" clears it."
                      },
                      "sub4": {
                        "type": "string",
                        "nullable": true,
                        "maxLength": 40,
                        "description": "Display name for sub4; null or \"\" clears it."
                      },
                      "sub5": {
                        "type": "string",
                        "nullable": true,
                        "maxLength": 40,
                        "description": "Display name for sub5; null or \"\" clears it."
                      }
                    },
                    "additionalProperties": false,
                    "description": "Partial update of sub1–sub5 display names — see below. Only the keys you send are touched."
                  },
                  "redirect_method": {
                    "type": "string",
                    "enum": [
                      "html",
                      "3xx"
                    ],
                    "description": "How the /serve → /track/click hop is delivered — see Ad serving."
                  }
                },
                "minProperties": 1
              },
              "example": {
                "sub_labels": {
                  "sub1": "Zone",
                  "sub3": null
                },
                "redirect_method": "3xx"
              }
            }
          }
        },
        "x-affset-permission": "write"
      }
    },
    "/api/stats": {
      "get": {
        "operationId": "statsGet",
        "summary": "Grouped traffic and conversion stats",
        "description": "- group_by=publisher_email needs owner, manager, or publisher_manager (scoped to their assigned publishers); group_by=advertiser_email needs owner, manager, or advertiser_manager (scoped to their assigned advertisers). The row key is the current zone/campaign owner — reassigning a zone or campaign re-attributes its history.\n- advertiser_email and publisher_email are also standalone filters (not just group_by values): passed as their own query params they narrow every row to one advertiser/publisher regardless of what group_by is set to, instead of breaking every user out into its own row. Same RBAC as the matching group_by.\n- group_by=status breaks conversions down by review state (pending | approved | rejected — the lifecycle behind hold windows and manual review). Like group_by=conversion_type, a status breakdown or filter covers conversion rows only, so impressions, clicks and click-derived cost are zero there.\n- from and to are UTC epoch milliseconds. With group_by=date, each returned date label is a calendar date in your tenant’s timezone (set via PUT /api/tenant).\n- Two different costs show up here: spend is Affset’s own campaign accounting — CPM campaigns accrue it per click at the campaign’s rate, CPA campaigns accrue it on conversion instead. media_cost is your actual traffic cost — the cost= click-time estimate from /serve or /track/click, except on the unfiltered group_by=date view, where complete days (and the current day-to-date) covered by traffic-source cost sync use the network’s own synced spend instead; partial historical days keep the estimate. The covered zones’ estimate is replaced, never added on top, and the synced_cost field on those rows shows the synced component. roi is computed from payout and media_cost, not spend, since that’s the number a media buyer is optimizing against — and it’s null (not 0) when there’s no cost data for that row.\n- Publisher-side roles (publisher, publisher_manager) never see spend. Advertiser-side roles (advertiser, advertiser_manager) never see payout, media_cost, or roi. Same redaction applies to Conversions.\n\nDocs: https://affset.com/docs#stats-get",
        "tags": [
          "Stats"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/XNamespace"
          },
          {
            "name": "from",
            "in": "query",
            "required": false,
            "description": "Default: start of today, UTC. Unix time in milliseconds.",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": false,
            "description": "Default: now. Unix time in milliseconds.",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "group_by",
            "in": "query",
            "required": false,
            "description": "date (default) | campaign_id | zone_id | country | conversion_type | status | publisher_email | advertiser_email | sub1…sub5.",
            "schema": {
              "type": "string",
              "enum": [
                "date",
                "campaign_id",
                "zone_id",
                "country",
                "conversion_type",
                "status",
                "publisher_email",
                "advertiser_email",
                "sub1",
                "sub2",
                "sub3",
                "sub4",
                "sub5"
              ],
              "default": "date"
            },
            "example": "zone_id"
          },
          {
            "name": "campaign_ids",
            "in": "query",
            "required": false,
            "description": "Restrict to these campaigns. Comma-separated list.",
            "schema": {
              "type": "string"
            },
            "example": "42"
          },
          {
            "name": "zone_ids",
            "in": "query",
            "required": false,
            "description": "Restrict to these zones. Comma-separated list.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "publisher_manager_email",
            "in": "query",
            "required": false,
            "description": "Restrict to zones owned by publishers assigned to this manager. Owner/manager may use any manager email; publisher_manager may use only their own.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "advertiser_email",
            "in": "query",
            "required": false,
            "description": "Narrow every row to one advertiser’s campaigns, independent of group_by. Owner/manager may use any advertiser email; advertiser_manager only one of their own assigned advertisers.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "publisher_email",
            "in": "query",
            "required": false,
            "description": "Narrow every row to one publisher’s zones, independent of group_by. Owner/manager may use any publisher email; publisher_manager only one of their own assigned publishers.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sub1",
            "in": "query",
            "required": false,
            "description": "A value, a comma list, or an empty string to match rows where that sub is unset.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sub2",
            "in": "query",
            "required": false,
            "description": "A value, a comma list, or an empty string to match rows where that sub is unset.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sub3",
            "in": "query",
            "required": false,
            "description": "A value, a comma list, or an empty string to match rows where that sub is unset.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sub4",
            "in": "query",
            "required": false,
            "description": "A value, a comma list, or an empty string to match rows where that sub is unset.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sub5",
            "in": "query",
            "required": false,
            "description": "A value, a comma list, or an empty string to match rows where that sub is unset.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "conversion_type",
            "in": "query",
            "required": false,
            "description": "Filter to specific conversion goal types — a value, a comma list, or an empty string to match conversions recorded without a type. Returns conversion rows only, so impressions, clicks and click-derived cost are zero in this slice.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "Conversion lifecycle filter: any comma list of pending, approved, rejected. Like conversion_type, this describes conversion rows only — impressions, clicks and click-derived cost are zero. Comma-separated list.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "paid_only",
            "in": "query",
            "required": false,
            "description": "true drops informative conversions (recorded with postback_skipped: non_goal_type and $0 money) from the conversions count — the same switch as on /api/conversions. Default false. Recent (raw) rows only: days already folded into the archive keep informative rows in their count. Money sums are unaffected either way.",
            "schema": {
              "type": "boolean",
              "default": false
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "stats": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "zone_id": {
                            "type": "string",
                            "format": "uuid"
                          },
                          "zone_name": {
                            "type": "string"
                          },
                          "impressions": {
                            "type": "integer"
                          },
                          "fallbacks": {
                            "type": "integer"
                          },
                          "unsold": {
                            "type": "integer"
                          },
                          "clicks": {
                            "type": "integer"
                          },
                          "conversions": {
                            "type": "integer"
                          },
                          "spend": {
                            "type": "number"
                          },
                          "payout": {
                            "type": "number"
                          },
                          "media_cost": {
                            "type": "number"
                          },
                          "roi": {
                            "type": "number"
                          }
                        }
                      }
                    },
                    "period": {
                      "type": "object",
                      "properties": {
                        "from": {
                          "type": "integer"
                        },
                        "to": {
                          "type": "integer"
                        }
                      }
                    },
                    "sub_labels": {
                      "type": "object",
                      "properties": {
                        "sub1": {
                          "type": "string"
                        }
                      }
                    }
                  }
                },
                "example": {
                  "stats": [
                    {
                      "zone_id": "b6e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b11",
                      "zone_name": "RichAds Push",
                      "impressions": 18400,
                      "fallbacks": 210,
                      "unsold": 40,
                      "clicks": 512,
                      "conversions": 9,
                      "spend": 12.8,
                      "payout": 27.5,
                      "media_cost": 14.2,
                      "roi": 0.9366
                    }
                  ],
                  "period": {
                    "from": 1753747200000,
                    "to": 1753833600000
                  },
                  "sub_labels": {
                    "sub1": "Zone"
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#stats-get"
        ],
        "x-affset-permission": "read"
      }
    },
    "/api/campaigns": {
      "get": {
        "operationId": "campaignsList",
        "summary": "List campaigns",
        "description": "- There’s no server-side name search — filter the page client-side if you need it.\n\nDocs: https://affset.com/docs#campaigns-list",
        "tags": [
          "Campaigns"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/XNamespace"
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "active | paused | archived. Omit for all statuses.",
            "schema": {
              "type": "string",
              "enum": [
                "active",
                "paused",
                "archived"
              ]
            },
            "example": "active"
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "1–100, default 20.",
            "schema": {
              "type": "integer",
              "default": 20,
              "minimum": 1,
              "maximum": 100
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Default 0.",
            "schema": {
              "type": "integer",
              "default": 0,
              "minimum": 0
            }
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "description": "name | created_at (default) | start_date.",
            "schema": {
              "type": "string",
              "enum": [
                "name",
                "created_at",
                "start_date"
              ],
              "default": "created_at"
            }
          },
          {
            "name": "order",
            "in": "query",
            "required": false,
            "description": "asc | desc (default).",
            "schema": {
              "type": "string",
              "enum": [
                "asc",
                "desc"
              ],
              "default": "desc"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "campaigns": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "integer"
                          },
                          "name": {
                            "type": "string"
                          },
                          "status": {
                            "type": "string"
                          },
                          "redirect_url": {
                            "type": "string"
                          },
                          "redirect_urls": {
                            "type": "array",
                            "items": {
                              "type": "string"
                            }
                          },
                          "payment_model": {
                            "type": "string"
                          },
                          "rate": {
                            "type": "integer"
                          },
                          "payout_goal_type": {},
                          "daily_budget": {},
                          "total_budget": {},
                          "pacing": {
                            "type": "string"
                          },
                          "start_date": {
                            "type": "integer"
                          },
                          "end_date": {},
                          "user_email": {
                            "type": "string",
                            "format": "email"
                          },
                          "created_at": {
                            "type": "integer"
                          }
                        }
                      }
                    },
                    "pagination": {
                      "type": "object",
                      "properties": {
                        "total": {
                          "type": "integer"
                        },
                        "limit": {
                          "type": "integer"
                        },
                        "offset": {
                          "type": "integer"
                        },
                        "has_more": {
                          "type": "boolean"
                        }
                      }
                    }
                  }
                },
                "example": {
                  "campaigns": [
                    {
                      "id": 42,
                      "name": "BR Sweepstakes — Push",
                      "status": "active",
                      "redirect_url": "https://offer.example/lp?s={click_id}",
                      "redirect_urls": [
                        "https://offer.example/lp?s={click_id}"
                      ],
                      "payment_model": "cpa",
                      "rate": 0,
                      "payout_goal_type": null,
                      "daily_budget": null,
                      "total_budget": null,
                      "pacing": "asap",
                      "start_date": 1753747200000,
                      "end_date": null,
                      "user_email": "buyer@example.com",
                      "created_at": 1753747200000
                    }
                  ],
                  "pagination": {
                    "total": 7,
                    "limit": 20,
                    "offset": 0,
                    "has_more": false
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#campaigns-list"
        ],
        "x-affset-permission": "read"
      },
      "post": {
        "operationId": "campaignsCreate",
        "summary": "Create a campaign",
        "description": "- New campaigns are always created paused — run it with PUT once it’s ready (see Update below).\n\nDocs: https://affset.com/docs#campaigns-create",
        "tags": [
          "Campaigns"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "201": {
            "description": "Created",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "integer"
                    },
                    "name": {
                      "type": "string"
                    },
                    "redirect_url": {
                      "type": "string"
                    },
                    "redirect_urls": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    },
                    "status": {
                      "type": "string"
                    },
                    "payout_goal_type": {},
                    "silent": {
                      "type": "integer"
                    },
                    "created_at": {
                      "type": "integer"
                    }
                  }
                },
                "example": {
                  "id": 42,
                  "name": "BR Sweepstakes — Push",
                  "redirect_url": "https://offer.example/lp?s={click_id}",
                  "redirect_urls": [
                    "https://offer.example/lp?s={click_id}"
                  ],
                  "status": "paused",
                  "payout_goal_type": null,
                  "silent": 0,
                  "created_at": 1753747200000
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#campaigns-create"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Campaign name."
                  },
                  "redirect_url": {
                    "type": "string",
                    "description": "Must be http(s). See Ad serving for the macros it can contain. Either this or redirect_urls is required."
                  },
                  "redirect_urls": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "1–10 http(s) URLs; clicks are split randomly between them (prelander rotation). Wins over redirect_url when both are sent. Repeat a URL to give it a bigger share."
                  },
                  "user_email": {
                    "type": "string",
                    "description": "Required for owner, manager and advertiser_manager — whose advertiser this bills to. Advertisers may omit it (defaults to themselves)."
                  },
                  "payment_model": {
                    "type": "string",
                    "enum": [
                      "cpm",
                      "cpa"
                    ],
                    "default": "cpm",
                    "description": "Default cpm."
                  },
                  "rate": {
                    "type": "number",
                    "default": 0,
                    "minimum": 0,
                    "description": "Default 0. Rounded to 2 decimals."
                  },
                  "payout_goal_type": {
                    "type": "string",
                    "nullable": true,
                    "description": "Only conversions whose pixel type= matches this exactly accrue spend/payout — others record at $0."
                  },
                  "silent": {
                    "type": "integer",
                    "minimum": 0,
                    "description": "0 disables silent conversions. A positive N makes every Nth conversion pay $0 and skip the affiliate postback; requires the silent_conversions feature flag."
                  },
                  "daily_budget": {
                    "type": "number",
                    "nullable": true,
                    "description": "0–999999999.99999."
                  },
                  "total_budget": {
                    "type": "number",
                    "nullable": true,
                    "description": "0–999999999.99999."
                  },
                  "pacing": {
                    "type": "string",
                    "enum": [
                      "asap",
                      "even"
                    ],
                    "default": "asap",
                    "description": "Default asap."
                  },
                  "start_date": {
                    "type": "integer",
                    "description": "Optional. Unix time in milliseconds."
                  },
                  "end_date": {
                    "type": "integer",
                    "description": "Optional. Unix time in milliseconds."
                  },
                  "targeting_rules": {
                    "type": "array",
                    "items": {
                      "$ref": "#/components/schemas/TargetingRuleInput"
                    },
                    "description": "Optional shortcut: [{ targeting_rule_type_id, targeting_method, rule }] — same shape as the Targeting sync endpoint. Convenient for geo at creation time; everything else, use Targeting after creating."
                  }
                },
                "required": [
                  "name",
                  "redirect_url"
                ]
              },
              "example": {
                "name": "BR Sweepstakes — Push",
                "redirect_url": "https://offer.example/lp?s={click_id}",
                "user_email": "buyer@example.com",
                "payment_model": "cpa",
                "targeting_rules": [
                  {
                    "targeting_rule_type_id": 1,
                    "targeting_method": "whitelist",
                    "rule": "BR,MX"
                  }
                ]
              }
            }
          }
        },
        "x-affset-permission": "write"
      }
    },
    "/api/campaigns/{campaign_id}": {
      "get": {
        "operationId": "campaignsGet",
        "summary": "Get one campaign",
        "description": "- 404 for a campaign that doesn’t exist or isn’t visible to your role — GET never reveals which.\n\nDocs: https://affset.com/docs#campaigns-get",
        "tags": [
          "Campaigns"
        ],
        "parameters": [
          {
            "name": "campaign_id",
            "in": "path",
            "required": true,
            "description": "Campaign id.",
            "schema": {
              "type": "integer"
            },
            "example": 42
          },
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "integer"
                    },
                    "name": {
                      "type": "string"
                    },
                    "status": {
                      "type": "string"
                    },
                    "redirect_url": {
                      "type": "string"
                    },
                    "redirect_urls": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    },
                    "payment_model": {
                      "type": "string"
                    },
                    "rate": {
                      "type": "integer"
                    },
                    "user_email": {
                      "type": "string",
                      "format": "email"
                    },
                    "targeting_rules": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "integer"
                          },
                          "targeting_rule_type_id": {
                            "type": "integer"
                          },
                          "targeting_method": {
                            "type": "string"
                          },
                          "rule": {
                            "type": "string"
                          }
                        }
                      }
                    }
                  }
                },
                "example": {
                  "id": 42,
                  "name": "BR Sweepstakes — Push",
                  "status": "active",
                  "redirect_url": "https://offer.example/lp?s={click_id}",
                  "redirect_urls": [
                    "https://offer.example/lp?s={click_id}",
                    "https://offer.example/lp-b?s={click_id}"
                  ],
                  "payment_model": "cpa",
                  "rate": 0,
                  "user_email": "buyer@example.com",
                  "targeting_rules": [
                    {
                      "id": 501,
                      "targeting_rule_type_id": 1,
                      "targeting_method": "whitelist",
                      "rule": "BR,MX"
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "404": {
            "$ref": "#/components/responses/Error404"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#campaigns-get"
        ],
        "x-affset-permission": "read"
      },
      "put": {
        "operationId": "campaignsUpdate",
        "summary": "Update a campaign (partial)",
        "description": "- Only the fields you send are changed — this is a partial update despite the PUT verb. An empty/unrecognized body is a 400.\n- Setting status to active runs your plan’s active-campaign check and can return 402.\n- Response is just { id, updated_at } — re-fetch with GET if you need the full row back.\n\nDocs: https://affset.com/docs#campaigns-update",
        "tags": [
          "Campaigns"
        ],
        "parameters": [
          {
            "name": "campaign_id",
            "in": "path",
            "required": true,
            "description": "Campaign id.",
            "schema": {
              "type": "integer"
            },
            "example": 42
          },
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "integer"
                    },
                    "updated_at": {
                      "type": "integer"
                    }
                  }
                },
                "example": {
                  "id": 42,
                  "updated_at": 1753887600000
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "402": {
            "$ref": "#/components/responses/Error402"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "404": {
            "$ref": "#/components/responses/Error404"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#campaigns-update"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "New campaign name."
                  },
                  "redirect_url": {
                    "type": "string",
                    "description": "New http(s) destination; supports the macros listed under Ad serving. Replaces the whole rotation set with this one URL."
                  },
                  "redirect_urls": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "1–10 http(s) URLs; clicks are split randomly between them. Replaces the existing set; wins over redirect_url when both are sent."
                  },
                  "status": {
                    "type": "string",
                    "enum": [
                      "active",
                      "paused",
                      "archived"
                    ],
                    "description": "Use this to run/pause/archive a campaign."
                  },
                  "payment_model": {
                    "type": "string",
                    "enum": [
                      "cpm",
                      "cpa"
                    ],
                    "description": "How campaign spend is calculated."
                  },
                  "rate": {
                    "type": "number",
                    "minimum": 0,
                    "description": "Non-negative; rounded to 2 decimals."
                  },
                  "payout_goal_type": {
                    "type": "string",
                    "nullable": true,
                    "description": "Send null or an empty string to clear the goal filter."
                  },
                  "silent": {
                    "type": "integer",
                    "minimum": 0,
                    "description": "Non-negative silent-conversion cadence; 0 disables it. A positive value requires the silent_conversions feature flag."
                  },
                  "daily_budget": {
                    "type": "number",
                    "nullable": true,
                    "description": "Send null to clear a budget."
                  },
                  "total_budget": {
                    "type": "number",
                    "nullable": true,
                    "description": "Send null to clear a budget."
                  },
                  "pacing": {
                    "type": "string",
                    "enum": [
                      "asap",
                      "even"
                    ],
                    "description": "Delivery pacing; even works against the daily budget."
                  },
                  "start_date": {
                    "type": "integer",
                    "nullable": true,
                    "description": "Send null to clear a boundary. Unix time in milliseconds."
                  },
                  "end_date": {
                    "type": "integer",
                    "nullable": true,
                    "description": "Send null to clear a boundary. Unix time in milliseconds."
                  },
                  "user_email": {
                    "type": "string",
                    "description": "advertiser_email works identically — both write the same field."
                  }
                },
                "minProperties": 1
              },
              "example": {
                "status": "active"
              }
            }
          }
        },
        "x-affset-permission": "write"
      },
      "delete": {
        "operationId": "campaignsDelete",
        "summary": "Delete a campaign",
        "description": "- Cascades its targeting rules. Conversions and click history are not deleted with it.\n\nDocs: https://affset.com/docs#campaigns-delete",
        "tags": [
          "Campaigns"
        ],
        "parameters": [
          {
            "name": "campaign_id",
            "in": "path",
            "required": true,
            "description": "Campaign id.",
            "schema": {
              "type": "integer"
            },
            "example": 42
          },
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "204": {
            "description": "No Content"
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "404": {
            "$ref": "#/components/responses/Error404"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#campaigns-delete"
        ],
        "x-affset-permission": "write"
      }
    },
    "/api/zones": {
      "get": {
        "operationId": "zonesList",
        "summary": "List zones",
        "description": "- manager_email is computed, not stored — it’s the publisher_manager who owns that zone’s publisher, if any.\n\nDocs: https://affset.com/docs#zones-list",
        "tags": [
          "Zones"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/XNamespace"
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "active | inactive.",
            "schema": {
              "type": "string",
              "enum": [
                "active",
                "inactive"
              ]
            },
            "example": "active"
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "1–100, default 20.",
            "schema": {
              "type": "integer",
              "default": 20,
              "minimum": 1,
              "maximum": 100
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Default 0.",
            "schema": {
              "type": "integer",
              "default": 0,
              "minimum": 0
            }
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "description": "name | created_at (default) | site_url.",
            "schema": {
              "type": "string",
              "enum": [
                "name",
                "created_at",
                "site_url"
              ],
              "default": "created_at"
            }
          },
          {
            "name": "order",
            "in": "query",
            "required": false,
            "description": "asc | desc (default).",
            "schema": {
              "type": "string",
              "enum": [
                "asc",
                "desc"
              ],
              "default": "desc"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "zones": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string",
                            "format": "uuid"
                          },
                          "name": {
                            "type": "string"
                          },
                          "status": {
                            "type": "string"
                          },
                          "site_url": {},
                          "traffic_back_url": {
                            "type": "string"
                          },
                          "postback_url": {
                            "type": "string"
                          },
                          "traffic_source_id": {},
                          "traffic_source_name": {},
                          "user_email": {
                            "type": "string",
                            "format": "email"
                          },
                          "manager_email": {},
                          "created_at": {
                            "type": "integer"
                          }
                        }
                      }
                    },
                    "pagination": {
                      "type": "object",
                      "properties": {
                        "total": {
                          "type": "integer"
                        },
                        "limit": {
                          "type": "integer"
                        },
                        "offset": {
                          "type": "integer"
                        },
                        "has_more": {
                          "type": "boolean"
                        }
                      }
                    }
                  }
                },
                "example": {
                  "zones": [
                    {
                      "id": "550e8400-e29b-41d4-a716-446655440000",
                      "name": "RichAds Push",
                      "status": "active",
                      "site_url": null,
                      "traffic_back_url": "https://richads.example/fallback",
                      "postback_url": "https://richads.example/pb?click_id={source_click_id}&payout={payout}",
                      "traffic_source_id": null,
                      "traffic_source_name": null,
                      "user_email": "publisher@example.com",
                      "manager_email": null,
                      "created_at": 1753747200000
                    }
                  ],
                  "pagination": {
                    "total": 3,
                    "limit": 20,
                    "offset": 0,
                    "has_more": false
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#zones-list"
        ],
        "x-affset-permission": "read"
      },
      "post": {
        "operationId": "zonesCreate",
        "summary": "Create a zone",
        "description": "- Zones are always created active.\n- Creating a zone counts against the plan’s zone limit and can return 402.\n- Affset doesn’t require {source_click_id} (or the legacy {sub_id}) in postback_url, but a postback without it can’t be matched back to a click — you’ll get a warning, not a rejection.\n- traffic_back_url is only the /serve fallback when no campaign can deliver — it is not a conversion postback.\n\nDocs: https://affset.com/docs#zones-create",
        "tags": [
          "Zones"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "201": {
            "description": "Created",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "status": {
                      "type": "string"
                    },
                    "created_at": {
                      "type": "integer"
                    }
                  }
                },
                "example": {
                  "id": "550e8400-e29b-41d4-a716-446655440000",
                  "status": "active",
                  "created_at": 1753747200000
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "402": {
            "$ref": "#/components/responses/Error402"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#zones-create"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Zone name."
                  },
                  "site_url": {
                    "type": "string",
                    "description": "Must be http(s) if present."
                  },
                  "traffic_back_url": {
                    "type": "string",
                    "description": "Where /serve sends traffic when there’s no eligible campaign. Must be http(s) if present."
                  },
                  "postback_url": {
                    "type": "string",
                    "description": "Affiliate passback — Affset GETs this on conversion. Macros: {payout}, {source_click_id} (alias {sub_id}), {sub1}…{sub5}. Must be http(s) if present."
                  },
                  "traffic_source_id": {
                    "type": "string",
                    "description": "Optional. Must reference a traffic source in this tenant; otherwise 400. Zone reads then include the joined traffic_source_name — see Traffic Sources."
                  },
                  "user_email": {
                    "type": "string",
                    "description": "Publishers can only create for themselves. Owner/manager/publisher_manager may assign any publisher."
                  }
                },
                "required": [
                  "name"
                ]
              },
              "example": {
                "name": "RichAds Push",
                "postback_url": "https://richads.example/pb?click_id={source_click_id}&payout={payout}"
              }
            }
          }
        },
        "x-affset-permission": "write"
      }
    },
    "/api/zones/{zone_id}": {
      "get": {
        "operationId": "zonesGet",
        "summary": "Get one zone",
        "description": "Docs: https://affset.com/docs#zones-get",
        "tags": [
          "Zones"
        ],
        "parameters": [
          {
            "name": "zone_id",
            "in": "path",
            "required": true,
            "description": "Zone id.",
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "example": "550e8400-e29b-41d4-a716-446655440000"
          },
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "name": {
                      "type": "string"
                    },
                    "status": {
                      "type": "string"
                    },
                    "postback_url": {
                      "type": "string"
                    },
                    "traffic_source_id": {},
                    "traffic_source_name": {}
                  }
                },
                "example": {
                  "id": "550e8400-e29b-41d4-a716-446655440000",
                  "name": "RichAds Push",
                  "status": "active",
                  "postback_url": "https://richads.example/pb?click_id={source_click_id}&payout={payout}",
                  "traffic_source_id": null,
                  "traffic_source_name": null
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "404": {
            "$ref": "#/components/responses/Error404"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#zones-get"
        ],
        "x-affset-permission": "read"
      },
      "put": {
        "operationId": "zonesUpdate",
        "summary": "Update a zone (partial)",
        "description": "Docs: https://affset.com/docs#zones-update",
        "tags": [
          "Zones"
        ],
        "parameters": [
          {
            "name": "zone_id",
            "in": "path",
            "required": true,
            "description": "Zone id.",
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "example": "550e8400-e29b-41d4-a716-446655440000"
          },
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "updated_at": {
                      "type": "integer"
                    }
                  }
                },
                "example": {
                  "id": "550e8400-e29b-41d4-a716-446655440000",
                  "updated_at": 1753887600000
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "404": {
            "$ref": "#/components/responses/Error404"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#zones-update"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "New zone name."
                  },
                  "status": {
                    "type": "string",
                    "enum": [
                      "active",
                      "inactive"
                    ],
                    "description": "Unlike campaigns, there’s no archived state for zones."
                  },
                  "site_url": {
                    "type": "string",
                    "nullable": true,
                    "description": "Send null to clear a URL."
                  },
                  "traffic_back_url": {
                    "type": "string",
                    "nullable": true,
                    "description": "Send null to clear a URL."
                  },
                  "postback_url": {
                    "type": "string",
                    "nullable": true,
                    "description": "Send null to clear a URL."
                  },
                  "traffic_source_id": {
                    "type": "string",
                    "nullable": true,
                    "description": "Link to a traffic source in this tenant, or send null / \"\" to unlink."
                  }
                },
                "minProperties": 1
              },
              "example": {
                "postback_url": null
              }
            }
          }
        },
        "x-affset-permission": "write"
      },
      "delete": {
        "operationId": "zonesDelete",
        "summary": "Delete a zone",
        "description": "Docs: https://affset.com/docs#zones-delete",
        "tags": [
          "Zones"
        ],
        "parameters": [
          {
            "name": "zone_id",
            "in": "path",
            "required": true,
            "description": "Zone id.",
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "example": "550e8400-e29b-41d4-a716-446655440000"
          },
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "204": {
            "description": "No Content"
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "404": {
            "$ref": "#/components/responses/Error404"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#zones-delete"
        ],
        "x-affset-permission": "write"
      }
    },
    "/api/api-keys": {
      "get": {
        "operationId": "teamList",
        "summary": "List team members or machine keys",
        "description": "Access: Owner and manager see the whole tenant. publisher_manager / advertiser_manager see only their own assigned publishers/advertisers.\n\n- ⚠️ Unlike the dashboard’s Team page, this response includes each member’s live bearer token in plaintext. If you’re building a UI, log, or support tool on top of this endpoint, redact token before you display or store it anywhere.\n- Returns a bare array — no pagination envelope, unlike every other list endpoint.\n- Members minted by POST /api/api-keys/solo (the owner’s own advertiser and publisher profiles) carry solo: true.\n\nDocs: https://affset.com/docs#team-list",
        "tags": [
          "Team"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/XNamespace"
          },
          {
            "name": "type",
            "in": "query",
            "required": true,
            "description": "user = people (dashboard/API logins). api-key = machine keys with no owning person.",
            "schema": {
              "type": "string",
              "enum": [
                "user",
                "api-key"
              ]
            },
            "example": "user"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "properties": {
                      "token": {
                        "type": "string"
                      },
                      "namespace": {
                        "type": "string"
                      },
                      "user_id": {
                        "type": "string"
                      },
                      "email": {
                        "type": "string",
                        "format": "email"
                      },
                      "role": {
                        "type": "string"
                      },
                      "created_at": {
                        "type": "integer"
                      },
                      "permissions": {
                        "type": "array",
                        "items": {
                          "type": "string"
                        }
                      },
                      "solo": {
                        "type": "boolean"
                      }
                    }
                  }
                },
                "example": [
                  {
                    "token": "<redacted — the live response contains the bearer token>",
                    "namespace": "acme-media",
                    "user_id": "usr_8f2a1c",
                    "email": "buyer@example.com",
                    "role": "advertiser",
                    "created_at": 1753747200000,
                    "permissions": [
                      "read",
                      "write"
                    ]
                  },
                  {
                    "token": "<redacted — the live response contains the bearer token>",
                    "namespace": "acme-media",
                    "user_id": "usr_2b91ad",
                    "email": "buyer+advertiser@example.com",
                    "role": "advertiser",
                    "created_at": 1753747200000,
                    "permissions": [
                      "read",
                      "write"
                    ],
                    "solo": true
                  }
                ]
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#team-list"
        ],
        "x-affset-permission": "read"
      },
      "post": {
        "operationId": "teamCreate",
        "summary": "Invite a team member / issue a machine key",
        "description": "Access: Owner and manager can create any role. publisher_manager can only create publisher (assigned to themselves). advertiser_manager can only create advertiser.\n\n- The plaintext token is returned here, by rotate, and by the list endpoint to permitted roles. Treat all three responses as secrets and avoid logging them.\n- This creates the key directly, like the dashboard’s \"Add team member\" — it does not send an invite email. Hand the token to the person yourself, over a channel you trust.\n- type=user is gated by the seat limit (402) and requires a verified tenant email address (403, code EMAIL_VERIFICATION_REQUIRED). type=api-key uses the separate machine-key plan limit.\n\nDocs: https://affset.com/docs#team-create",
        "tags": [
          "Team"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/XNamespace"
          },
          {
            "name": "type",
            "in": "query",
            "required": true,
            "description": "Use user for a person with an email, or api-key for a machine credential.",
            "schema": {
              "type": "string",
              "enum": [
                "user",
                "api-key"
              ]
            },
            "example": "user"
          }
        ],
        "responses": {
          "201": {
            "description": "Created",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "token": {
                      "type": "string"
                    },
                    "namespace": {
                      "type": "string"
                    },
                    "email": {
                      "type": "string",
                      "format": "email"
                    },
                    "role": {
                      "type": "string"
                    },
                    "permissions": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    },
                    "created_at": {
                      "type": "integer"
                    }
                  }
                },
                "example": {
                  "token": "<new live bearer token>",
                  "namespace": "acme-media",
                  "email": "sarah@offer.com",
                  "role": "publisher",
                  "permissions": [
                    "read",
                    "write"
                  ],
                  "created_at": 1753747200000
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "402": {
            "$ref": "#/components/responses/Error402"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#team-create"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "email": {
                    "type": "string",
                    "description": "Required when type=user, ignored for api-key."
                  },
                  "role": {
                    "type": "string",
                    "enum": [
                      "owner",
                      "manager",
                      "advertiser",
                      "advertiser_manager",
                      "publisher",
                      "publisher_manager"
                    ],
                    "description": "owner | manager | publisher | advertiser | advertiser_manager | publisher_manager."
                  },
                  "permissions": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "enum": [
                        "read",
                        "write"
                      ]
                    },
                    "default": [
                      "read",
                      "write"
                    ],
                    "description": "Any of read, write. Default [\"read\",\"write\"]."
                  },
                  "manager_email": {
                    "type": "string",
                    "description": "Only valid for type=user when role is publisher or advertiser."
                  },
                  "expires_at": {
                    "type": "integer",
                    "description": "Optional future expiration time. Unix time in milliseconds."
                  }
                },
                "required": [
                  "role"
                ]
              },
              "example": {
                "email": "sarah@offer.com",
                "role": "publisher"
              }
            }
          }
        },
        "x-affset-permission": "write"
      },
      "delete": {
        "operationId": "teamRevokeRemoveTerminate",
        "summary": "Revoke a team member or key / Permanently remove a team member or key / Close the account",
        "description": "**Revoke a team member or key**\n\n- Sets the key’s expiry to now. For a user key, it also pauses campaigns and deactivates zones owned by that email. History stays intact.\n\nDocs: https://affset.com/docs#team-revoke\n\n---\n\n**Permanently remove a team member or key**\n\n- Hard-deletes an expired key. For a user key, it also deletes campaigns and zones owned by that email; removing a machine key deletes only the credential. Revoke an active key first.\n\nDocs: https://affset.com/docs#team-remove\n\n---\n\n**Close the account**\n\nAccess: Owner only.\n\n- ⚠️ Deletes the entire tenant — every campaign, zone, team member and history record. Irreversible. This is account closure, not team management.\n\nDocs: https://affset.com/docs#team-terminate",
        "tags": [
          "Team"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "204": {
            "description": "No Content"
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#team-revoke",
          "https://affset.com/docs#team-remove",
          "https://affset.com/docs#team-terminate"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "token": {
                    "type": "string",
                    "description": "Bearer token to revoke."
                  },
                  "action": {
                    "type": "string",
                    "enum": [
                      "revoke",
                      "remove",
                      "terminate"
                    ],
                    "description": "One of: revoke, remove, terminate."
                  }
                },
                "required": [
                  "token",
                  "action"
                ]
              },
              "examples": {
                "team-revoke": {
                  "summary": "Revoke a team member or key",
                  "value": {
                    "token": "sk_live_...",
                    "action": "revoke"
                  }
                },
                "team-remove": {
                  "summary": "Permanently remove a team member or key",
                  "value": {
                    "token": "sk_live_...",
                    "action": "remove"
                  }
                },
                "team-terminate": {
                  "summary": "Close the account",
                  "value": {
                    "token": "sk_live_...",
                    "action": "terminate"
                  }
                }
              }
            }
          }
        },
        "x-affset-permission": "write"
      },
      "patch": {
        "operationId": "teamRotateSetManager",
        "summary": "Rotate a token / Reassign who manages this person",
        "description": "**Rotate a token**\n\n- Issues a new token for the same identity and invalidates the old one immediately.\n\nDocs: https://affset.com/docs#team-rotate\n\n---\n\n**Reassign who manages this person**\n\nDocs: https://affset.com/docs#team-set-manager",
        "tags": [
          "Team"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/XNamespace"
          },
          {
            "name": "type",
            "in": "query",
            "required": false,
            "description": "Required for: Reassign who manages this person (type=user).",
            "schema": {
              "type": "string",
              "enum": [
                "user"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "token": {
                      "type": "string"
                    },
                    "role": {
                      "type": "string"
                    },
                    "permissions": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    },
                    "user_id": {
                      "type": "string"
                    },
                    "manager_email": {
                      "type": "string",
                      "format": "email"
                    }
                  }
                },
                "examples": {
                  "team-rotate": {
                    "summary": "Rotate a token",
                    "value": {
                      "token": "<new live bearer token>",
                      "role": "publisher",
                      "permissions": [
                        "read",
                        "write"
                      ]
                    }
                  },
                  "team-set-manager": {
                    "summary": "Reassign who manages this person",
                    "value": {
                      "user_id": "usr_8f2a1c",
                      "manager_email": "manager@acme-media.com"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#team-rotate",
          "https://affset.com/docs#team-set-manager"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "token": {
                    "type": "string",
                    "description": "Active bearer token to replace."
                  },
                  "action": {
                    "type": "string",
                    "enum": [
                      "rotate"
                    ],
                    "description": "Must be rotate."
                  },
                  "manager_email": {
                    "type": "string",
                    "nullable": true,
                    "description": "The publisher_manager/advertiser_manager they report to. null clears it."
                  }
                },
                "required": [
                  "token"
                ]
              },
              "examples": {
                "team-rotate": {
                  "summary": "Rotate a token",
                  "value": {
                    "token": "sk_live_...",
                    "action": "rotate"
                  }
                },
                "team-set-manager": {
                  "summary": "Reassign who manages this person",
                  "value": {
                    "token": "sk_live_...",
                    "manager_email": "manager@acme-media.com"
                  }
                }
              }
            }
          }
        },
        "x-affset-permission": "write"
      }
    },
    "/api/api-keys/solo": {
      "post": {
        "operationId": "teamSolo",
        "summary": "Set up solo — become your own advertiser and publisher",
        "description": "Access: Owner only. Non-owners get 403. An owner machine key without an email gets 400 — sign in as a person.\n\n- Campaigns must belong to an advertiser and zones to a publisher. A solo media buyer is both, so this mints the two profiles in one call — the dashboard’s \"Set up solo\" button — instead of inviting yourself twice.\n- The profiles live on plus-addressed aliases of the owner’s email (owner+advertiser@…, owner+publisher@…): distinct tenant users that still belong to your inbox. Nothing is ever mailed to them, and no tokens are returned — keep using your own key.\n- Idempotent: existing active profiles are reused and reported with created: []. Seat headroom for every missing profile is checked before anything is written (402 PLAN_LIMIT_REACHED on seats); both profiles count as team seats.\n- Not subject to the email-verification gate that POST ?type=user applies — that gate stops invite mail to third parties, and solo sends nothing.\n- 409 with code SOLO_PROFILE_REVOKED when a revoked profile still holds an alias: remove it (DELETE action remove) and call again. 409 without that code when the alias already exists as a different role.\n\nDocs: https://affset.com/docs#team-solo",
        "tags": [
          "Team"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "200": {
            "description": "OK — Both profiles already existed",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "advertiser": {
                      "type": "object",
                      "properties": {
                        "user_id": {
                          "type": "string"
                        },
                        "email": {
                          "type": "string",
                          "format": "email"
                        },
                        "role": {
                          "type": "string"
                        }
                      }
                    },
                    "publisher": {
                      "type": "object",
                      "properties": {
                        "user_id": {
                          "type": "string"
                        },
                        "email": {
                          "type": "string",
                          "format": "email"
                        },
                        "role": {
                          "type": "string"
                        }
                      }
                    },
                    "created": {
                      "type": "array",
                      "items": {}
                    }
                  }
                },
                "example": {
                  "advertiser": {
                    "user_id": "usr_2b91ad",
                    "email": "buyer+advertiser@example.com",
                    "role": "advertiser"
                  },
                  "publisher": {
                    "user_id": "usr_2b91ae",
                    "email": "buyer+publisher@example.com",
                    "role": "publisher"
                  },
                  "created": []
                }
              }
            }
          },
          "201": {
            "description": "Created — One or both profiles minted",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "advertiser": {
                      "type": "object",
                      "properties": {
                        "user_id": {
                          "type": "string"
                        },
                        "email": {
                          "type": "string",
                          "format": "email"
                        },
                        "role": {
                          "type": "string"
                        }
                      }
                    },
                    "publisher": {
                      "type": "object",
                      "properties": {
                        "user_id": {
                          "type": "string"
                        },
                        "email": {
                          "type": "string",
                          "format": "email"
                        },
                        "role": {
                          "type": "string"
                        }
                      }
                    },
                    "created": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    }
                  }
                },
                "example": {
                  "advertiser": {
                    "user_id": "usr_2b91ad",
                    "email": "buyer+advertiser@example.com",
                    "role": "advertiser"
                  },
                  "publisher": {
                    "user_id": "usr_2b91ae",
                    "email": "buyer+publisher@example.com",
                    "role": "publisher"
                  },
                  "created": [
                    "advertiser",
                    "publisher"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "402": {
            "$ref": "#/components/responses/Error402"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "409": {
            "$ref": "#/components/responses/Error409"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#team-solo"
        ],
        "x-affset-permission": "write"
      }
    },
    "/api/campaigns/{campaign_id}/payout_rules": {
      "get": {
        "operationId": "payoutList",
        "summary": "List a campaign’s payout rules",
        "description": "Docs: https://affset.com/docs#payout-list",
        "tags": [
          "Payout rules"
        ],
        "parameters": [
          {
            "name": "campaign_id",
            "in": "path",
            "required": true,
            "description": "Campaign id.",
            "schema": {
              "type": "integer"
            },
            "example": 42
          },
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "payout_rules": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "integer"
                          },
                          "campaign_id": {
                            "type": "integer"
                          },
                          "zone_id": {
                            "type": "string",
                            "format": "uuid",
                            "nullable": true
                          },
                          "payout": {
                            "type": "number"
                          },
                          "created_at": {
                            "type": "integer"
                          }
                        }
                      }
                    }
                  }
                },
                "example": {
                  "payout_rules": [
                    {
                      "id": 12,
                      "campaign_id": 42,
                      "zone_id": null,
                      "payout": 2.5,
                      "created_at": 1753747200000
                    },
                    {
                      "id": 13,
                      "campaign_id": 42,
                      "zone_id": "550e8400-e29b-41d4-a716-446655440000",
                      "payout": 3,
                      "created_at": 1753747200000
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "404": {
            "$ref": "#/components/responses/Error404"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#payout-list"
        ],
        "x-affset-permission": "read"
      },
      "post": {
        "operationId": "payoutCreate",
        "summary": "Create a payout rule",
        "description": "- Resolution at conversion time: zone-specific rule, then the global rule, then $0.\n- 409 if a rule already exists for that exact campaign + zone (or campaign + global) — see \"Changing a payout\" below.\n\nDocs: https://affset.com/docs#payout-create",
        "tags": [
          "Payout rules"
        ],
        "parameters": [
          {
            "name": "campaign_id",
            "in": "path",
            "required": true,
            "description": "Campaign id.",
            "schema": {
              "type": "integer"
            },
            "example": 42
          },
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "201": {
            "description": "Created",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "integer"
                    },
                    "campaign_id": {
                      "type": "integer"
                    },
                    "zone_id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "payout": {
                      "type": "integer"
                    },
                    "created_at": {
                      "type": "integer"
                    }
                  }
                },
                "example": {
                  "id": 13,
                  "campaign_id": 42,
                  "zone_id": "550e8400-e29b-41d4-a716-446655440000",
                  "payout": 3,
                  "created_at": 1753747200000
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "404": {
            "$ref": "#/components/responses/Error404"
          },
          "409": {
            "$ref": "#/components/responses/Error409"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#payout-create"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "payout": {
                    "type": "number",
                    "minimum": 0.00001,
                    "maximum": 9999.99999,
                    "description": "0.00001–9999.99999."
                  },
                  "zone_id": {
                    "type": "string",
                    "description": "Omit for the global (fallback) rule."
                  }
                },
                "required": [
                  "payout"
                ]
              },
              "example": {
                "payout": 3,
                "zone_id": "550e8400-e29b-41d4-a716-446655440000"
              }
            }
          }
        },
        "x-affset-permission": "write"
      },
      "delete": {
        "operationId": "payoutDelete",
        "summary": "Delete a payout rule",
        "description": "Docs: https://affset.com/docs#payout-delete",
        "tags": [
          "Payout rules"
        ],
        "parameters": [
          {
            "name": "campaign_id",
            "in": "path",
            "required": true,
            "description": "Campaign id.",
            "schema": {
              "type": "integer"
            },
            "example": 42
          },
          {
            "$ref": "#/components/parameters/XNamespace"
          },
          {
            "name": "zone_id",
            "in": "query",
            "required": false,
            "description": "Omit to delete the global rule.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "No Content"
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "404": {
            "$ref": "#/components/responses/Error404"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#payout-delete"
        ],
        "x-affset-permission": "write"
      }
    },
    "/api/traffic-source-presets": {
      "get": {
        "operationId": "trafficSourcePresetsList",
        "summary": "List traffic source presets",
        "description": "- Built-in presets: exoclick, trafficstars, propellerads, adsterra, richads. `[BRACKETED]` pieces in a postback_template are account-specific values you fill in after copying it into your own traffic source.\n- tracking_template is the query string to append to a zone /serve (or /track/click) URL — your parameter names on the left, the network’s own macros on the right; the network expands them before the request reaches Affset.\n\nDocs: https://affset.com/docs#traffic-source-presets-list",
        "tags": [
          "Traffic Sources"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "presets": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string"
                          },
                          "name": {
                            "type": "string"
                          },
                          "doc_url": {
                            "type": "string"
                          },
                          "tracking_template": {
                            "type": "string"
                          },
                          "postback_template": {
                            "type": "string"
                          },
                          "sub_meanings": {
                            "type": "object",
                            "properties": {
                              "sub1": {
                                "type": "string"
                              },
                              "sub2": {
                                "type": "string"
                              },
                              "sub3": {
                                "type": "string"
                              },
                              "sub4": {
                                "type": "string"
                              },
                              "sub5": {
                                "type": "string"
                              }
                            }
                          },
                          "notes": {
                            "type": "string"
                          }
                        }
                      }
                    }
                  }
                },
                "example": {
                  "presets": [
                    {
                      "id": "exoclick",
                      "name": "ExoClick",
                      "doc_url": "https://docs.exoclick.com/advertisers/campaigns/macros",
                      "tracking_template": "source_click_id={conversions_tracking}&cost={cost}&sub1={zone_id}&sub2={site_id}&sub3={variation_id}&sub4={campaign_id}&sub5={format}",
                      "postback_template": "https://s.magsrv.com/tag.php?goal=[GOAL_ID]&tag={source_click_id}&value={payout}",
                      "sub_meanings": {
                        "sub1": "Zone id",
                        "sub2": "Site id",
                        "sub3": "Variation id",
                        "sub4": "Campaign id",
                        "sub5": "Format"
                      },
                      "notes": "Replace [GOAL_ID] with the Conversion Goal ID from your ExoClick account…"
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#traffic-source-presets-list"
        ],
        "x-affset-permission": "read"
      }
    },
    "/api/traffic-sources": {
      "get": {
        "operationId": "trafficSourcesList",
        "summary": "List traffic sources",
        "description": "Docs: https://affset.com/docs#traffic-sources-list",
        "tags": [
          "Traffic Sources"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/XNamespace"
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "active | archived.",
            "schema": {
              "type": "string",
              "enum": [
                "active",
                "archived"
              ]
            },
            "example": "active"
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "1–100, default 50.",
            "schema": {
              "type": "integer",
              "default": 50,
              "minimum": 1,
              "maximum": 100
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Default 0.",
            "schema": {
              "type": "integer",
              "default": 0,
              "minimum": 0
            }
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "description": "name | created_at (default).",
            "schema": {
              "type": "string",
              "enum": [
                "name",
                "created_at"
              ],
              "default": "created_at"
            }
          },
          {
            "name": "order",
            "in": "query",
            "required": false,
            "description": "asc | desc (default).",
            "schema": {
              "type": "string",
              "enum": [
                "asc",
                "desc"
              ],
              "default": "desc"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "traffic_sources": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string",
                            "format": "uuid"
                          },
                          "name": {
                            "type": "string"
                          },
                          "preset": {
                            "type": "string"
                          },
                          "tracking_template": {
                            "type": "string"
                          },
                          "postback_template": {
                            "type": "string"
                          },
                          "has_api_token": {
                            "type": "boolean"
                          },
                          "status": {
                            "type": "string"
                          },
                          "created_at": {
                            "type": "integer"
                          },
                          "updated_at": {
                            "type": "integer"
                          }
                        }
                      }
                    },
                    "pagination": {
                      "type": "object",
                      "properties": {
                        "total": {
                          "type": "integer"
                        },
                        "limit": {
                          "type": "integer"
                        },
                        "offset": {
                          "type": "integer"
                        },
                        "has_more": {
                          "type": "boolean"
                        }
                      }
                    }
                  }
                },
                "example": {
                  "traffic_sources": [
                    {
                      "id": "b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22",
                      "name": "ExoClick — main",
                      "preset": "exoclick",
                      "tracking_template": "source_click_id={conversions_tracking}&cost={cost}&sub1={zone_id}&sub2={site_id}&sub3={variation_id}&sub4={campaign_id}&sub5={format}",
                      "postback_template": "https://s.magsrv.com/tag.php?goal=abc123&tag={source_click_id}&value={payout}",
                      "has_api_token": false,
                      "status": "active",
                      "created_at": 1755640000000,
                      "updated_at": 1755640000000
                    }
                  ],
                  "pagination": {
                    "total": 1,
                    "limit": 50,
                    "offset": 0,
                    "has_more": false
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#traffic-sources-list"
        ],
        "x-affset-permission": "read"
      },
      "post": {
        "operationId": "trafficSourcesCreate",
        "summary": "Create a traffic source",
        "description": "- 409 if the name already exists in the namespace.\n\nDocs: https://affset.com/docs#traffic-sources-create",
        "tags": [
          "Traffic Sources"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "201": {
            "description": "Created",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "status": {
                      "type": "string"
                    },
                    "created_at": {
                      "type": "integer"
                    }
                  }
                },
                "example": {
                  "id": "b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22",
                  "status": "active",
                  "created_at": 1755640000000
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "409": {
            "$ref": "#/components/responses/Error409"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#traffic-sources-create"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Unique within the tenant. Max 200 characters."
                  },
                  "preset": {
                    "type": "string",
                    "enum": [
                      "exoclick",
                      "trafficstars",
                      "propellerads",
                      "adsterra",
                      "richads"
                    ],
                    "description": "Copying a preset (exoclick | trafficstars | propellerads | adsterra | richads) fills tracking_template / postback_template unless you also send your own; the row stays fully editable and remembers its preset."
                  },
                  "tracking_template": {
                    "type": "string",
                    "description": "Raw query string appended after the zone URL’s \"?\" — must not start with \"?\"/\"&\" and no control characters. Max 2000 characters. Network macro syntaxes ({x}, ${X}, ##X##, [X]) pass through byte-exact."
                  },
                  "postback_template": {
                    "type": "string",
                    "description": "The network’s S2S conversion endpoint, using the same macros as a zone postback_url ({payout}, {source_click_id}, {sub1}…{sub5}). Must be http(s) if present. Max 2000 characters."
                  },
                  "api_token": {
                    "type": "string",
                    "description": "The network’s API credential, stored write-only; powers cost sync, blacklist push, and bid management for supported presets. Max 500 characters, no control characters."
                  },
                  "status": {
                    "type": "string",
                    "enum": [
                      "active",
                      "archived"
                    ],
                    "default": "active",
                    "description": "Default active."
                  }
                },
                "required": [
                  "name"
                ]
              },
              "example": {
                "name": "ExoClick — main",
                "preset": "exoclick",
                "api_token": "your-exoclick-api-token"
              }
            }
          }
        },
        "x-affset-permission": "write"
      }
    },
    "/api/traffic-sources/{traffic_source_id}": {
      "get": {
        "operationId": "trafficSourcesGet",
        "summary": "Get one traffic source",
        "description": "- Same shape as List, plus linked_zones — how many zones currently reference it.\n\nDocs: https://affset.com/docs#traffic-sources-get",
        "tags": [
          "Traffic Sources"
        ],
        "parameters": [
          {
            "name": "traffic_source_id",
            "in": "path",
            "required": true,
            "description": "Traffic source id.",
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "example": "b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22"
          },
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "name": {
                      "type": "string"
                    },
                    "preset": {
                      "type": "string"
                    },
                    "tracking_template": {
                      "type": "string"
                    },
                    "postback_template": {
                      "type": "string"
                    },
                    "has_api_token": {
                      "type": "boolean"
                    },
                    "status": {
                      "type": "string"
                    },
                    "linked_zones": {
                      "type": "integer"
                    },
                    "created_at": {
                      "type": "integer"
                    },
                    "updated_at": {
                      "type": "integer"
                    }
                  }
                },
                "example": {
                  "id": "b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22",
                  "name": "ExoClick — main",
                  "preset": "exoclick",
                  "tracking_template": "source_click_id={conversions_tracking}&cost={cost}&sub1={zone_id}",
                  "postback_template": "https://s.magsrv.com/tag.php?goal=abc123&tag={source_click_id}&value={payout}",
                  "has_api_token": false,
                  "status": "active",
                  "linked_zones": 3,
                  "created_at": 1755640000000,
                  "updated_at": 1755640000000
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "404": {
            "$ref": "#/components/responses/Error404"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#traffic-sources-get"
        ],
        "x-affset-permission": "read"
      },
      "put": {
        "operationId": "trafficSourcesUpdate",
        "summary": "Update a traffic source (partial)",
        "description": "- At least one field is required.\n\nDocs: https://affset.com/docs#traffic-sources-update",
        "tags": [
          "Traffic Sources"
        ],
        "parameters": [
          {
            "name": "traffic_source_id",
            "in": "path",
            "required": true,
            "description": "Traffic source id.",
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "example": "b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22"
          },
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "updated_at": {
                      "type": "integer"
                    }
                  }
                },
                "example": {
                  "id": "b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22",
                  "updated_at": 1755650000000
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "404": {
            "$ref": "#/components/responses/Error404"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#traffic-sources-update"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "New name; must stay unique within the tenant."
                  },
                  "preset": {
                    "type": "string",
                    "enum": [
                      "exoclick",
                      "trafficstars",
                      "propellerads",
                      "adsterra",
                      "richads"
                    ],
                    "nullable": true,
                    "description": "exoclick | trafficstars | propellerads | adsterra | richads. null or \"\" clears it (without touching the templates already on the row)."
                  },
                  "tracking_template": {
                    "type": "string",
                    "description": "Replaces the stored template; send \"\" to clear it."
                  },
                  "postback_template": {
                    "type": "string",
                    "description": "Replaces the stored template; send \"\" to clear it."
                  },
                  "api_token": {
                    "type": "string",
                    "nullable": true,
                    "description": "Omit to leave unchanged, null or \"\" to clear, or a string to replace it."
                  },
                  "status": {
                    "type": "string",
                    "enum": [
                      "active",
                      "archived"
                    ],
                    "description": "New status."
                  }
                },
                "minProperties": 1
              },
              "example": {
                "api_token": null,
                "status": "active"
              }
            }
          }
        },
        "x-affset-permission": "write"
      },
      "delete": {
        "operationId": "trafficSourcesDelete",
        "summary": "Delete a traffic source",
        "description": "- 409 with linked_zones while any zone still references it — unlink those zones or set status to archived instead (keeps history attributable).\n\nDocs: https://affset.com/docs#traffic-sources-delete",
        "tags": [
          "Traffic Sources"
        ],
        "parameters": [
          {
            "name": "traffic_source_id",
            "in": "path",
            "required": true,
            "description": "Traffic source id.",
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "example": "b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22"
          },
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "204": {
            "description": "No Content"
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "404": {
            "$ref": "#/components/responses/Error404"
          },
          "409": {
            "$ref": "#/components/responses/Error409"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#traffic-sources-delete"
        ],
        "x-affset-permission": "write"
      }
    },
    "/api/traffic-sources/{traffic_source_id}/sync-costs": {
      "post": {
        "operationId": "trafficSourcesSyncCosts",
        "summary": "Pull spend from the network now",
        "description": "- Each synced day is replaced wholesale with what the network reports, so revised numbers never double count. rows_skipped counts malformed or out-of-window rows the network returned (normally 0).\n- 422 when the source has no api_token or its preset has no cost adapter (supported: exoclick, trafficstars, richads). 502 with the network’s failure reason when the network refuses — stored costs are left untouched.\n\nDocs: https://affset.com/docs#traffic-sources-sync-costs",
        "tags": [
          "Traffic Sources"
        ],
        "parameters": [
          {
            "name": "traffic_source_id",
            "in": "path",
            "required": true,
            "description": "Traffic source id.",
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "example": "b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22"
          },
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "synced": {
                      "type": "boolean"
                    },
                    "date_from": {
                      "type": "string"
                    },
                    "date_to": {
                      "type": "string"
                    },
                    "days": {
                      "type": "integer"
                    },
                    "rows_synced": {
                      "type": "integer"
                    },
                    "rows_skipped": {
                      "type": "integer"
                    },
                    "cost_total": {
                      "type": "number"
                    }
                  }
                },
                "example": {
                  "synced": true,
                  "date_from": "2026-08-01",
                  "date_to": "2026-08-28",
                  "days": 28,
                  "rows_synced": 54,
                  "rows_skipped": 0,
                  "cost_total": 148.52
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "404": {
            "$ref": "#/components/responses/Error404"
          },
          "422": {
            "$ref": "#/components/responses/Error422"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#traffic-sources-sync-costs"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "date_from": {
                    "type": "string",
                    "description": "Inclusive YYYY-MM-DD start of the window to sync. Optional — omit both dates to sync the hourly job’s own window (today plus the two previous UTC days)."
                  },
                  "date_to": {
                    "type": "string",
                    "description": "Inclusive YYYY-MM-DD end. Not in the future; at most 31 days after date_from. Send an explicit range once after adding a token to backfill history."
                  }
                }
              },
              "example": {
                "date_from": "2026-08-01",
                "date_to": "2026-08-28"
              }
            }
          }
        },
        "x-affset-permission": "write"
      }
    },
    "/api/traffic-sources/{traffic_source_id}/check-credentials": {
      "post": {
        "operationId": "trafficSourcesCheckCredentials",
        "summary": "Verify the stored API token against the network",
        "description": "- One authentication round-trip with the stored api_token — nothing is stored, nothing is revealed. ok: false carries the network’s rejection reason; 422 for presets without a cost adapter.\n\nDocs: https://affset.com/docs#traffic-sources-check-credentials",
        "tags": [
          "Traffic Sources"
        ],
        "parameters": [
          {
            "name": "traffic_source_id",
            "in": "path",
            "required": true,
            "description": "Traffic source id.",
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "example": "b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22"
          },
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "error": {}
                  }
                },
                "example": {
                  "ok": true,
                  "error": null
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "404": {
            "$ref": "#/components/responses/Error404"
          },
          "422": {
            "$ref": "#/components/responses/Error422"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#traffic-sources-check-credentials"
        ],
        "x-affset-permission": "write"
      }
    },
    "/api/traffic-sources/{traffic_source_id}/blocks": {
      "post": {
        "operationId": "trafficSourcesBlocksAdd",
        "summary": "Add blocked placements and push them to the network",
        "description": "- Pushed through the network’s own API — exoclick zones, trafficstars spots and sites, richads sites. push_status is the entry’s current state: pending (no api_token yet), pushed (every targeted campaign accepted it), partial (some refused; push_error says how many), failed, or manual (no API path for this preset/kind — apply it in the network panel; the list is still your record). Account-wide pushes that need one call per campaign cover at most 50 campaigns — past that, scope the entry to a campaign.\n- push is null when nothing was pushable (push: false, or every entry is manual); push.error is set when the network could not be reached at all. A source holds at most 2000 entries. Removing an entry later is local only — the network keeps the block.\n\nDocs: https://affset.com/docs#traffic-sources-blocks-add",
        "tags": [
          "Traffic Sources"
        ],
        "parameters": [
          {
            "name": "traffic_source_id",
            "in": "path",
            "required": true,
            "description": "Traffic source id.",
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "example": "b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22"
          },
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "201": {
            "description": "Created",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "blocks": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string",
                            "format": "uuid"
                          },
                          "source_id": {
                            "type": "string",
                            "format": "uuid"
                          },
                          "kind": {
                            "type": "string"
                          },
                          "value": {
                            "type": "string"
                          },
                          "network_campaign_id": {
                            "type": "string"
                          },
                          "reason": {
                            "type": "string"
                          },
                          "push_status": {
                            "type": "string"
                          },
                          "push_error": {},
                          "pushed_at": {
                            "type": "integer"
                          },
                          "created_at": {
                            "type": "integer"
                          },
                          "updated_at": {
                            "type": "integer"
                          }
                        }
                      }
                    },
                    "push": {
                      "type": "object",
                      "properties": {
                        "attempted": {
                          "type": "integer"
                        },
                        "pushed": {
                          "type": "integer"
                        },
                        "partial": {
                          "type": "integer"
                        },
                        "failed": {
                          "type": "integer"
                        },
                        "manual": {
                          "type": "integer"
                        },
                        "skipped": {
                          "type": "integer"
                        },
                        "error": {}
                      }
                    }
                  }
                },
                "example": {
                  "blocks": [
                    {
                      "id": "7c1e6a2e-3b3f-4c1d-9e0a-5f6b7c8d9e01",
                      "source_id": "b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22",
                      "kind": "zone",
                      "value": "2154141",
                      "network_campaign_id": "",
                      "reason": "0 conversions on $42",
                      "push_status": "pushed",
                      "push_error": null,
                      "pushed_at": 1756900000000,
                      "created_at": 1756900000000,
                      "updated_at": 1756900000000
                    }
                  ],
                  "push": {
                    "attempted": 1,
                    "pushed": 1,
                    "partial": 0,
                    "failed": 0,
                    "manual": 0,
                    "skipped": 0,
                    "error": null
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "404": {
            "$ref": "#/components/responses/Error404"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#traffic-sources-blocks-add"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "blocks": {
                    "type": "array",
                    "items": {},
                    "description": "1–100 entries of { kind, value, network_campaign_id?, reason? }. kind is \"zone\" (the placement/zone/spot id carried in sub1) or \"site\" (the site id in sub2); value is the network’s own id (≤64 chars — numeric for adapter-backed networks, RichAds sites are the 32-character id); network_campaign_id scopes the block to one network campaign (\"\" or omitted = every campaign in the account at push time); reason ≤500 chars."
                  },
                  "push": {
                    "type": "boolean",
                    "default": true,
                    "description": "Push right away (default true). Re-adding an existing (kind, value, campaign) entry is idempotent — it updates the reason and pushes again."
                  }
                },
                "required": [
                  "blocks"
                ]
              },
              "example": {
                "blocks": [
                  {
                    "kind": "zone",
                    "value": "2154141",
                    "reason": "0 conversions on $42"
                  },
                  {
                    "kind": "site",
                    "value": "88012",
                    "network_campaign_id": "913"
                  }
                ],
                "push": true
              }
            }
          }
        },
        "x-affset-permission": "write"
      },
      "get": {
        "operationId": "trafficSourcesBlocksList",
        "summary": "List blocked placements",
        "description": "- Newest first.\n\nDocs: https://affset.com/docs#traffic-sources-blocks-list",
        "tags": [
          "Traffic Sources"
        ],
        "parameters": [
          {
            "name": "traffic_source_id",
            "in": "path",
            "required": true,
            "description": "Traffic source id.",
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "example": "b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22"
          },
          {
            "$ref": "#/components/parameters/XNamespace"
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "pending | pushed | partial | failed | manual.",
            "schema": {
              "type": "string",
              "enum": [
                "pending",
                "pushed",
                "partial",
                "failed",
                "manual"
              ]
            },
            "example": "failed"
          },
          {
            "name": "kind",
            "in": "query",
            "required": false,
            "description": "zone | site.",
            "schema": {
              "type": "string",
              "enum": [
                "zone",
                "site"
              ]
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "1–500, default 100.",
            "schema": {
              "type": "integer",
              "default": 100,
              "minimum": 1,
              "maximum": 500
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Default 0.",
            "schema": {
              "type": "integer",
              "default": 0,
              "minimum": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "blocks": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string",
                            "format": "uuid"
                          },
                          "source_id": {
                            "type": "string",
                            "format": "uuid"
                          },
                          "kind": {
                            "type": "string"
                          },
                          "value": {
                            "type": "string"
                          },
                          "network_campaign_id": {
                            "type": "string"
                          },
                          "reason": {
                            "type": "string"
                          },
                          "push_status": {
                            "type": "string"
                          },
                          "push_error": {},
                          "pushed_at": {
                            "type": "integer"
                          },
                          "created_at": {
                            "type": "integer"
                          },
                          "updated_at": {
                            "type": "integer"
                          }
                        }
                      }
                    },
                    "pagination": {
                      "type": "object",
                      "properties": {
                        "total": {
                          "type": "integer"
                        },
                        "limit": {
                          "type": "integer"
                        },
                        "offset": {
                          "type": "integer"
                        },
                        "has_more": {
                          "type": "boolean"
                        }
                      }
                    }
                  }
                },
                "example": {
                  "blocks": [
                    {
                      "id": "7c1e6a2e-3b3f-4c1d-9e0a-5f6b7c8d9e01",
                      "source_id": "b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22",
                      "kind": "zone",
                      "value": "2154141",
                      "network_campaign_id": "",
                      "reason": "0 conversions on $42",
                      "push_status": "pushed",
                      "push_error": null,
                      "pushed_at": 1756900000000,
                      "created_at": 1756900000000,
                      "updated_at": 1756900000000
                    }
                  ],
                  "pagination": {
                    "total": 1,
                    "limit": 100,
                    "offset": 0,
                    "has_more": false
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "404": {
            "$ref": "#/components/responses/Error404"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#traffic-sources-blocks-list"
        ],
        "x-affset-permission": "read"
      }
    },
    "/api/traffic-sources/{traffic_source_id}/blocks/push": {
      "post": {
        "operationId": "trafficSourcesBlocksPush",
        "summary": "Retry pushing blocked placements",
        "description": "- 422 when the preset has no push adapter or the source has no api_token; 502 (same body) when the network refused before any entry was pushed.\n\nDocs: https://affset.com/docs#traffic-sources-blocks-push",
        "tags": [
          "Traffic Sources"
        ],
        "parameters": [
          {
            "name": "traffic_source_id",
            "in": "path",
            "required": true,
            "description": "Traffic source id.",
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "example": "b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22"
          },
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "attempted": {
                      "type": "integer"
                    },
                    "pushed": {
                      "type": "integer"
                    },
                    "partial": {
                      "type": "integer"
                    },
                    "failed": {
                      "type": "integer"
                    },
                    "manual": {
                      "type": "integer"
                    },
                    "skipped": {
                      "type": "integer"
                    },
                    "error": {},
                    "blocks": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string",
                            "format": "uuid"
                          },
                          "source_id": {
                            "type": "string",
                            "format": "uuid"
                          },
                          "kind": {
                            "type": "string"
                          },
                          "value": {
                            "type": "string"
                          },
                          "network_campaign_id": {
                            "type": "string"
                          },
                          "reason": {
                            "type": "string"
                          },
                          "push_status": {
                            "type": "string"
                          },
                          "push_error": {},
                          "pushed_at": {
                            "type": "integer"
                          },
                          "created_at": {
                            "type": "integer"
                          },
                          "updated_at": {
                            "type": "integer"
                          }
                        }
                      }
                    }
                  }
                },
                "example": {
                  "attempted": 1,
                  "pushed": 1,
                  "partial": 0,
                  "failed": 0,
                  "manual": 0,
                  "skipped": 0,
                  "error": null,
                  "blocks": [
                    {
                      "id": "7c1e6a2e-3b3f-4c1d-9e0a-5f6b7c8d9e01",
                      "source_id": "b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22",
                      "kind": "zone",
                      "value": "2154141",
                      "network_campaign_id": "",
                      "reason": "0 conversions on $42",
                      "push_status": "pushed",
                      "push_error": null,
                      "pushed_at": 1756900000000,
                      "created_at": 1756900000000,
                      "updated_at": 1756900000000
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "404": {
            "$ref": "#/components/responses/Error404"
          },
          "422": {
            "$ref": "#/components/responses/Error422"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#traffic-sources-blocks-push"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "block_ids": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "1–100 block ids to push regardless of status — how an account-wide block reaches campaigns created since. Omit (or send no body) to push every pending / failed / partial entry, up to 100."
                  }
                }
              },
              "example": {
                "block_ids": [
                  "7c1e6a2e-3b3f-4c1d-9e0a-5f6b7c8d9e01"
                ]
              }
            }
          }
        },
        "x-affset-permission": "write"
      }
    },
    "/api/traffic-sources/{traffic_source_id}/blocks/{block_id}": {
      "delete": {
        "operationId": "trafficSourcesBlocksDelete",
        "summary": "Remove a blocked placement (local only)",
        "description": "- Forgets the entry and its push log locally; the network keeps whatever was pushed (un-blocking semantics differ per network and are not automated).\n\nDocs: https://affset.com/docs#traffic-sources-blocks-delete",
        "tags": [
          "Traffic Sources"
        ],
        "parameters": [
          {
            "name": "traffic_source_id",
            "in": "path",
            "required": true,
            "description": "Traffic source id.",
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "example": "b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22"
          },
          {
            "name": "block_id",
            "in": "path",
            "required": true,
            "description": "Blocked-placement entry id.",
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "example": "7c1e6a2e-3b3f-4c1d-9e0a-5f6b7c8d9e01"
          },
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "204": {
            "description": "No Content"
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "404": {
            "$ref": "#/components/responses/Error404"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#traffic-sources-blocks-delete"
        ],
        "x-affset-permission": "write"
      }
    },
    "/api/traffic-sources/{traffic_source_id}/block-pushes": {
      "get": {
        "operationId": "trafficSourcesBlockPushes",
        "summary": "Push log",
        "description": "- One row per (entry, network campaign, attempt), newest first. message is the network’s refusal reason, or an informational no-op note when ok.\n\nDocs: https://affset.com/docs#traffic-sources-block-pushes",
        "tags": [
          "Traffic Sources"
        ],
        "parameters": [
          {
            "name": "traffic_source_id",
            "in": "path",
            "required": true,
            "description": "Traffic source id.",
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "example": "b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22"
          },
          {
            "$ref": "#/components/parameters/XNamespace"
          },
          {
            "name": "block_id",
            "in": "query",
            "required": false,
            "description": "Only this entry’s rows.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "1–200, default 50.",
            "schema": {
              "type": "integer",
              "default": 50,
              "minimum": 1,
              "maximum": 200
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "pushes": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string",
                            "format": "uuid"
                          },
                          "block_id": {
                            "type": "string",
                            "format": "uuid"
                          },
                          "kind": {
                            "type": "string"
                          },
                          "value": {
                            "type": "string"
                          },
                          "network_campaign_id": {
                            "type": "string"
                          },
                          "ok": {
                            "type": "boolean"
                          },
                          "message": {
                            "type": "string"
                          },
                          "pushed_at": {
                            "type": "integer"
                          }
                        }
                      }
                    }
                  }
                },
                "example": {
                  "pushes": [
                    {
                      "id": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b",
                      "block_id": "7c1e6a2e-3b3f-4c1d-9e0a-5f6b7c8d9e01",
                      "kind": "zone",
                      "value": "2154141",
                      "network_campaign_id": "913",
                      "ok": true,
                      "message": "Spot is already in the blacklist",
                      "pushed_at": 1756900000000
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "404": {
            "$ref": "#/components/responses/Error404"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#traffic-sources-block-pushes"
        ],
        "x-affset-permission": "read"
      }
    },
    "/api/traffic-sources/{traffic_source_id}/bids": {
      "get": {
        "operationId": "trafficSourcesBidsList",
        "summary": "Network campaigns with their current bids",
        "description": "- Read live from the network account on every call — Affset never caches a bid. status is active | paused | other (status_label keeps the network’s own word); pricing_model is cpc | cpm | cpa | cpv | smart_cpm | smart_cpc | smart_bid, or \"\" when unknown; bid is null when the network reported none; extra carries network figures that are shown, not written (TrafficStars price_rtb). last_change is the most recent entry that reached Affset’s mutation stage (pending | applied | failed; pending means refresh the live bid before retrying).\n- 422 when the source has no api_token or its preset has no bid adapter (supported: exoclick, trafficstars, richads); 502 with the network’s failure reason when it refuses.\n\nDocs: https://affset.com/docs#traffic-sources-bids-list",
        "tags": [
          "Traffic Sources"
        ],
        "parameters": [
          {
            "name": "traffic_source_id",
            "in": "path",
            "required": true,
            "description": "Traffic source id.",
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "example": "b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22"
          },
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "campaigns": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "network_campaign_id": {
                            "type": "string"
                          },
                          "name": {
                            "type": "string"
                          },
                          "status": {
                            "type": "string"
                          },
                          "status_label": {
                            "type": "string"
                          },
                          "bid": {
                            "type": "number"
                          },
                          "currency": {
                            "type": "string"
                          },
                          "pricing_model": {
                            "type": "string"
                          },
                          "extra": {
                            "type": "object",
                            "properties": {
                              "price_rtb": {
                                "type": "number"
                              }
                            }
                          },
                          "last_change": {}
                        }
                      }
                    },
                    "fetched_at": {
                      "type": "integer"
                    }
                  }
                },
                "example": {
                  "campaigns": [
                    {
                      "network_campaign_id": "913",
                      "name": "Push RON — US",
                      "status": "active",
                      "status_label": "enabled",
                      "bid": 0.2,
                      "currency": "USD",
                      "pricing_model": "cpm",
                      "extra": {
                        "price_rtb": 0.5
                      },
                      "last_change": null
                    }
                  ],
                  "fetched_at": 1757671200000
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "404": {
            "$ref": "#/components/responses/Error404"
          },
          "422": {
            "$ref": "#/components/responses/Error422"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#traffic-sources-bids-list"
        ],
        "x-affset-permission": "read"
      },
      "post": {
        "operationId": "trafficSourcesBidsSet",
        "summary": "Set a network campaign’s bid",
        "description": "- The audit row is reserved as pending before external I/O, then finalized. Applied means the network echoed the new value back (ExoClick is re-read after its PUT); a write the network silently ignored is recorded as failed. audit_finalized=false means the network applied the bid but the row remains pending — refresh live bids before retrying. Per network: exoclick writes pricing.price keeping the campaign’s model; trafficstars writes price (direct traffic — price_rtb is untouched); richads writes the campaign bid. changed_by is the caller’s email (\"\" for the master API key).\n- ⚠️ 409 with code CONFIRM_LARGE_INCREASE (body: previous_bid, bid, factor) when the new bid is more than 5× the current one — nothing is sent to the network; repeat with allow_large_increase: true. Lowering a bid is never guarded.\n- 409 with code BID_CHANGED when expected_current_bid no longer matches the fresh network read — nothing is sent; refresh and review the change again.\n- 400 invalid body (nothing sent); 404 the pre-write read found no such campaign (nothing recorded); 422 the network refused the value — below its minimum, a campaign type without a bid — with the reason as error and the attempt recorded as failed; 502 the network could not be reached, the campaign disappeared during the write, or did not echo the bid (the reserved attempt is finalized as failed when possible).\n\nDocs: https://affset.com/docs#traffic-sources-bids-set",
        "tags": [
          "Traffic Sources"
        ],
        "parameters": [
          {
            "name": "traffic_source_id",
            "in": "path",
            "required": true,
            "description": "Traffic source id.",
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "example": "b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22"
          },
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "change": {
                      "type": "object",
                      "properties": {
                        "id": {
                          "type": "string",
                          "format": "uuid"
                        },
                        "source_id": {
                          "type": "string",
                          "format": "uuid"
                        },
                        "network_campaign_id": {
                          "type": "string"
                        },
                        "previous_bid": {
                          "type": "number"
                        },
                        "bid": {
                          "type": "number"
                        },
                        "currency": {
                          "type": "string"
                        },
                        "pricing_model": {
                          "type": "string"
                        },
                        "status": {
                          "type": "string"
                        },
                        "error": {},
                        "changed_by": {
                          "type": "string",
                          "format": "email"
                        },
                        "created_at": {
                          "type": "integer"
                        }
                      }
                    },
                    "campaign": {
                      "type": "object",
                      "properties": {
                        "network_campaign_id": {
                          "type": "string"
                        },
                        "name": {
                          "type": "string"
                        },
                        "status": {
                          "type": "string"
                        },
                        "bid": {
                          "type": "number"
                        },
                        "previous_bid": {
                          "type": "number"
                        },
                        "currency": {
                          "type": "string"
                        },
                        "pricing_model": {
                          "type": "string"
                        }
                      }
                    },
                    "audit_finalized": {
                      "type": "boolean"
                    }
                  }
                },
                "example": {
                  "change": {
                    "id": "9d2e4f10-6a7b-4c8d-9e0f-1a2b3c4d5e6f",
                    "source_id": "b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22",
                    "network_campaign_id": "913",
                    "previous_bid": 0.2,
                    "bid": 0.25,
                    "currency": "USD",
                    "pricing_model": "cpm",
                    "status": "applied",
                    "error": null,
                    "changed_by": "buyer@example.com",
                    "created_at": 1757671200000
                  },
                  "campaign": {
                    "network_campaign_id": "913",
                    "name": "Push RON — US",
                    "status": "active",
                    "bid": 0.25,
                    "previous_bid": 0.2,
                    "currency": "USD",
                    "pricing_model": "cpm"
                  },
                  "audit_finalized": true
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "404": {
            "$ref": "#/components/responses/Error404"
          },
          "409": {
            "$ref": "#/components/responses/Error409"
          },
          "422": {
            "$ref": "#/components/responses/Error422"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#traffic-sources-bids-set"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "network_campaign_id": {
                    "type": "string",
                    "description": "The network’s numeric campaign id — the value your sub4 slot carries."
                  },
                  "bid": {
                    "type": "number",
                    "minimum": 0.000001,
                    "maximum": 1000,
                    "description": "New bid in USD using the campaign’s existing pricing model: positive, at most 1000, at most 6 decimal places. For RichAds, CPM for pops and CPC for push/display. The network applies its own minimum."
                  },
                  "expected_current_bid": {
                    "type": "number",
                    "nullable": true,
                    "minimum": 0,
                    "description": "The bid (or null) from the live bids response that the caller reviewed. The write gets 409 BID_CHANGED if the network value moved meanwhile."
                  },
                  "allow_large_increase": {
                    "type": "boolean",
                    "default": false,
                    "description": "Required to raise a bid to more than 5× the current one (see the 409 below). Never send it by default."
                  }
                },
                "required": [
                  "network_campaign_id",
                  "bid",
                  "expected_current_bid"
                ]
              },
              "example": {
                "network_campaign_id": "913",
                "bid": 0.25,
                "expected_current_bid": 0.2
              }
            }
          }
        },
        "x-affset-permission": "write"
      }
    },
    "/api/traffic-sources/{traffic_source_id}/bid-changes": {
      "get": {
        "operationId": "trafficSourcesBidChanges",
        "summary": "Bid history",
        "description": "- Newest first. This is Affset’s record of what entered the mutation stage (pending, applied or failed, by whom); the current bid is always the one GET …/bids reads from the network. A pending row means refresh live bids before retrying.\n\nDocs: https://affset.com/docs#traffic-sources-bid-changes",
        "tags": [
          "Traffic Sources"
        ],
        "parameters": [
          {
            "name": "traffic_source_id",
            "in": "path",
            "required": true,
            "description": "Traffic source id.",
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "example": "b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22"
          },
          {
            "$ref": "#/components/parameters/XNamespace"
          },
          {
            "name": "network_campaign_id",
            "in": "query",
            "required": false,
            "description": "Only this campaign’s changes.",
            "schema": {
              "type": "string"
            },
            "example": "913"
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "1–200, default 50.",
            "schema": {
              "type": "integer",
              "default": 50,
              "minimum": 1,
              "maximum": 200
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "changes": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string",
                            "format": "uuid"
                          },
                          "source_id": {
                            "type": "string",
                            "format": "uuid"
                          },
                          "network_campaign_id": {
                            "type": "string"
                          },
                          "previous_bid": {
                            "type": "number"
                          },
                          "bid": {
                            "type": "number"
                          },
                          "currency": {
                            "type": "string"
                          },
                          "pricing_model": {
                            "type": "string"
                          },
                          "status": {
                            "type": "string"
                          },
                          "error": {},
                          "changed_by": {
                            "type": "string",
                            "format": "email"
                          },
                          "created_at": {
                            "type": "integer"
                          }
                        }
                      }
                    },
                    "large_increase_factor": {
                      "type": "integer"
                    }
                  }
                },
                "example": {
                  "changes": [
                    {
                      "id": "9d2e4f10-6a7b-4c8d-9e0f-1a2b3c4d5e6f",
                      "source_id": "b3e1e6b0-2f2a-4a3e-9c8e-1c9a2f6d4b22",
                      "network_campaign_id": "913",
                      "previous_bid": 0.2,
                      "bid": 0.25,
                      "currency": "USD",
                      "pricing_model": "cpm",
                      "status": "applied",
                      "error": null,
                      "changed_by": "buyer@example.com",
                      "created_at": 1757671200000
                    }
                  ],
                  "large_increase_factor": 5
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "404": {
            "$ref": "#/components/responses/Error404"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#traffic-sources-bid-changes"
        ],
        "x-affset-permission": "read"
      }
    },
    "/api/offers": {
      "get": {
        "operationId": "offersList",
        "summary": "List offers",
        "description": "- Publisher-side entries carry the stripped shape instead: no destination_url, revenue_cents, caps, campaign_id or user_email — plus link_issued (whether the caller already holds a tracking link) and application_status (the caller’s own latest application, or null).\n- Only active offers that are public or apply, or a private offer the caller already holds a link for, are listed for publisher-side roles.\n\nDocs: https://affset.com/docs#offers-list",
        "tags": [
          "Offers"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/XNamespace"
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "active | paused | archived.",
            "schema": {
              "type": "string",
              "enum": [
                "active",
                "paused",
                "archived"
              ]
            },
            "example": "active"
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "1–100, default 50.",
            "schema": {
              "type": "integer",
              "default": 50,
              "minimum": 1,
              "maximum": 100
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Default 0.",
            "schema": {
              "type": "integer",
              "default": 0,
              "minimum": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "offers": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "integer"
                          },
                          "name": {
                            "type": "string"
                          },
                          "description": {},
                          "category": {},
                          "status": {
                            "type": "string"
                          },
                          "destination_url": {
                            "type": "string"
                          },
                          "preview_url": {},
                          "visibility": {
                            "type": "string"
                          },
                          "hold_days": {
                            "type": "integer"
                          },
                          "auto_approve": {
                            "type": "boolean"
                          },
                          "daily_conversion_cap": {},
                          "monthly_payout_cap_cents": {},
                          "per_affiliate_daily_cap": {},
                          "allowed_traffic": {
                            "type": "array",
                            "items": {
                              "type": "string"
                            }
                          },
                          "terms": {},
                          "campaign_id": {
                            "type": "integer"
                          },
                          "user_email": {
                            "type": "string",
                            "format": "email"
                          },
                          "created_at": {
                            "type": "integer"
                          },
                          "updated_at": {
                            "type": "integer"
                          },
                          "goals": {
                            "type": "array",
                            "items": {
                              "type": "object",
                              "properties": {
                                "id": {
                                  "type": "integer"
                                },
                                "name": {
                                  "type": "string"
                                },
                                "conversion_type": {
                                  "type": "string"
                                },
                                "payout_cents": {
                                  "type": "integer"
                                },
                                "revenue_cents": {
                                  "type": "integer"
                                },
                                "sort": {
                                  "type": "integer"
                                }
                              }
                            }
                          }
                        }
                      }
                    },
                    "pagination": {
                      "type": "object",
                      "properties": {
                        "total": {
                          "type": "integer"
                        },
                        "limit": {
                          "type": "integer"
                        },
                        "offset": {
                          "type": "integer"
                        },
                        "has_more": {
                          "type": "boolean"
                        }
                      }
                    }
                  }
                },
                "example": {
                  "offers": [
                    {
                      "id": 12,
                      "name": "Sweeps SOI — US",
                      "description": null,
                      "category": null,
                      "status": "active",
                      "destination_url": "https://landing.example/sweeps?clid={click_id}",
                      "preview_url": null,
                      "visibility": "public",
                      "hold_days": 7,
                      "auto_approve": true,
                      "daily_conversion_cap": null,
                      "monthly_payout_cap_cents": null,
                      "per_affiliate_daily_cap": null,
                      "allowed_traffic": [
                        "push",
                        "pop"
                      ],
                      "terms": null,
                      "campaign_id": 345,
                      "user_email": "advertiser@yournetwork.com",
                      "created_at": 1755640000000,
                      "updated_at": 1755640000000,
                      "goals": [
                        {
                          "id": 30,
                          "name": "Registration",
                          "conversion_type": "reg",
                          "payout_cents": 250,
                          "revenue_cents": 400,
                          "sort": 0
                        },
                        {
                          "id": 31,
                          "name": "First deposit",
                          "conversion_type": "dep",
                          "payout_cents": 2500,
                          "revenue_cents": 4000,
                          "sort": 1
                        }
                      ]
                    }
                  ],
                  "pagination": {
                    "total": 1,
                    "limit": 50,
                    "offset": 0,
                    "has_more": false
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#offers-list"
        ],
        "x-affset-permission": "read"
      },
      "post": {
        "operationId": "offersCreate",
        "summary": "Create an offer",
        "description": "- Each offer materializes to exactly one serving campaign (campaign_id) — serving, targeting, budgets and payout rules are the existing campaign machinery underneath it. Offer-owned campaign fields (name, destination_url, status) must be managed through this endpoint, not through Campaigns directly.\n- Activating an offer (status: active) consumes the tenant’s active-campaign plan allowance and can return 402.\n- On a conversion, the pixel’s type is matched against the offer’s goals: a match writes spend = revenue_cents/100, payout = payout_cents/100 (a zone-scoped payout rule still overrides the payout for that affiliate); a non-matching or missing type records the conversion at $0/$0 with postback_skipped: non_goal_type.\n\nDocs: https://affset.com/docs#offers-create",
        "tags": [
          "Offers"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "201": {
            "description": "Created",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "integer"
                    },
                    "campaign_id": {
                      "type": "integer"
                    },
                    "status": {
                      "type": "string"
                    },
                    "created_at": {
                      "type": "integer"
                    }
                  }
                },
                "example": {
                  "id": 12,
                  "campaign_id": 345,
                  "status": "active",
                  "created_at": 1755640000000
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "402": {
            "$ref": "#/components/responses/Error402"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#offers-create"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Offer name."
                  },
                  "destination_url": {
                    "type": "string",
                    "description": "Must be http(s). Accepts the same macros as a campaign redirect_url, e.g. {click_id} — see Ad serving."
                  },
                  "status": {
                    "type": "string",
                    "enum": [
                      "active",
                      "paused",
                      "archived"
                    ],
                    "default": "paused",
                    "description": "Default paused."
                  },
                  "visibility": {
                    "type": "string",
                    "enum": [
                      "public",
                      "apply",
                      "private"
                    ],
                    "default": "public",
                    "description": "public lets any publisher self-issue a link; apply requires an approved application first; private is granted only by an operator or the offer’s advertiser. Default public."
                  },
                  "goals": {
                    "type": "array",
                    "items": {
                      "$ref": "#/components/schemas/OfferGoalInput"
                    },
                    "description": "1–20 entries: { name, conversion_type, payout_cents, revenue_cents, sort? }. conversion_type ([A-Za-z0-9_.-]{1,64}) must equal the conversion pixel’s type= parameter and be unique within the offer. revenue_cents (what the advertiser pays) minus payout_cents (what the affiliate earns) is the network margin."
                  },
                  "user_email": {
                    "type": "string",
                    "description": "Whose advertiser this belongs to — required for owner, manager and advertiser_manager, same ownership rule as Campaigns. Advertisers may omit it (defaults to themselves). The materialized campaign inherits the same user."
                  },
                  "description": {
                    "type": "string",
                    "description": "Optional."
                  },
                  "category": {
                    "type": "string",
                    "description": "Optional."
                  },
                  "preview_url": {
                    "type": "string",
                    "description": "Optional. Must be http(s) if present."
                  },
                  "terms": {
                    "type": "string",
                    "description": "Optional."
                  },
                  "allowed_traffic": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "Optional free-form traffic type tags, e.g. [\"push\", \"pop\"]."
                  },
                  "hold_days": {
                    "type": "integer",
                    "default": 0,
                    "minimum": 0,
                    "maximum": 90,
                    "description": "0–90 — how long a conversion sits in review before it’s eligible to pay out. Default 0."
                  },
                  "auto_approve": {
                    "type": "boolean",
                    "default": true,
                    "description": "Skip manual review and clear the hold automatically once hold_days elapses. Default true."
                  },
                  "daily_conversion_cap": {
                    "type": "integer",
                    "minimum": 1,
                    "description": "Max conversions/day across the offer — later ones land rejected with status_reason cap_exceeded. Optional, no cap by default."
                  },
                  "monthly_payout_cap_cents": {
                    "type": "integer",
                    "minimum": 1,
                    "description": "Max total payout cents/month across the offer. Optional."
                  },
                  "per_affiliate_daily_cap": {
                    "type": "integer",
                    "minimum": 1,
                    "description": "Max conversions/day for a single affiliate. Optional."
                  }
                },
                "required": [
                  "name",
                  "destination_url",
                  "goals"
                ]
              },
              "example": {
                "name": "Sweeps SOI — US",
                "destination_url": "https://landing.example/sweeps?clid={click_id}",
                "status": "active",
                "visibility": "public",
                "hold_days": 7,
                "user_email": "advertiser@yournetwork.com",
                "goals": [
                  {
                    "name": "Registration",
                    "conversion_type": "reg",
                    "payout_cents": 250,
                    "revenue_cents": 400
                  },
                  {
                    "name": "First deposit",
                    "conversion_type": "dep",
                    "payout_cents": 2500,
                    "revenue_cents": 4000
                  }
                ]
              }
            }
          }
        },
        "x-affset-permission": "write"
      }
    },
    "/api/offers/{offer_id}": {
      "get": {
        "operationId": "offersGet",
        "summary": "Get one offer",
        "description": "- Role-shaped as in List. A publisher holding a link for this offer also gets tracking_link and zone_id in the response.\n\nDocs: https://affset.com/docs#offers-get",
        "tags": [
          "Offers"
        ],
        "parameters": [
          {
            "name": "offer_id",
            "in": "path",
            "required": true,
            "description": "Offer id.",
            "schema": {
              "type": "integer"
            },
            "example": 12
          },
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "integer"
                    },
                    "name": {
                      "type": "string"
                    },
                    "description": {},
                    "category": {},
                    "status": {
                      "type": "string"
                    },
                    "destination_url": {
                      "type": "string"
                    },
                    "preview_url": {},
                    "visibility": {
                      "type": "string"
                    },
                    "hold_days": {
                      "type": "integer"
                    },
                    "auto_approve": {
                      "type": "boolean"
                    },
                    "daily_conversion_cap": {},
                    "monthly_payout_cap_cents": {},
                    "per_affiliate_daily_cap": {},
                    "allowed_traffic": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    },
                    "terms": {},
                    "campaign_id": {
                      "type": "integer"
                    },
                    "user_email": {
                      "type": "string",
                      "format": "email"
                    },
                    "created_at": {
                      "type": "integer"
                    },
                    "updated_at": {
                      "type": "integer"
                    },
                    "goals": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "integer"
                          },
                          "name": {
                            "type": "string"
                          },
                          "conversion_type": {
                            "type": "string"
                          },
                          "payout_cents": {
                            "type": "integer"
                          },
                          "revenue_cents": {
                            "type": "integer"
                          },
                          "sort": {
                            "type": "integer"
                          }
                        }
                      }
                    }
                  }
                },
                "example": {
                  "id": 12,
                  "name": "Sweeps SOI — US",
                  "description": null,
                  "category": null,
                  "status": "active",
                  "destination_url": "https://landing.example/sweeps?clid={click_id}",
                  "preview_url": null,
                  "visibility": "public",
                  "hold_days": 7,
                  "auto_approve": true,
                  "daily_conversion_cap": null,
                  "monthly_payout_cap_cents": null,
                  "per_affiliate_daily_cap": null,
                  "allowed_traffic": [
                    "push",
                    "pop"
                  ],
                  "terms": null,
                  "campaign_id": 345,
                  "user_email": "advertiser@yournetwork.com",
                  "created_at": 1755640000000,
                  "updated_at": 1755640000000,
                  "goals": [
                    {
                      "id": 30,
                      "name": "Registration",
                      "conversion_type": "reg",
                      "payout_cents": 250,
                      "revenue_cents": 400,
                      "sort": 0
                    },
                    {
                      "id": 31,
                      "name": "First deposit",
                      "conversion_type": "dep",
                      "payout_cents": 2500,
                      "revenue_cents": 4000,
                      "sort": 1
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "404": {
            "$ref": "#/components/responses/Error404"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#offers-get"
        ],
        "x-affset-permission": "read"
      },
      "put": {
        "operationId": "offersUpdate",
        "summary": "Update an offer (partial)",
        "description": "- Only the fields you send are changed — any subset of the create fields.\n- Setting status to active runs the tenant’s active-campaign plan check and can return 402.\n\nDocs: https://affset.com/docs#offers-update",
        "tags": [
          "Offers"
        ],
        "parameters": [
          {
            "name": "offer_id",
            "in": "path",
            "required": true,
            "description": "Offer id.",
            "schema": {
              "type": "integer"
            },
            "example": 12
          },
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "integer"
                    },
                    "updated_at": {
                      "type": "integer"
                    }
                  }
                },
                "example": {
                  "id": 12,
                  "updated_at": 1755650000000
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "402": {
            "$ref": "#/components/responses/Error402"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "404": {
            "$ref": "#/components/responses/Error404"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#offers-update"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Propagates to the materialized campaign."
                  },
                  "destination_url": {
                    "type": "string",
                    "description": "Propagates to the materialized campaign’s redirect_url."
                  },
                  "status": {
                    "type": "string",
                    "enum": [
                      "active",
                      "paused",
                      "archived"
                    ],
                    "description": "Propagates to the materialized campaign: active serves, paused/archived pause it."
                  },
                  "visibility": {
                    "type": "string",
                    "enum": [
                      "public",
                      "apply",
                      "private"
                    ],
                    "description": "See Create an offer."
                  },
                  "goals": {
                    "type": "array",
                    "items": {
                      "$ref": "#/components/schemas/OfferGoalInput"
                    },
                    "description": "Replaces the whole list — same shape as Create (name, conversion_type, payout_cents, revenue_cents, sort?). Send every goal you want to keep; ids are ignored and new ids are assigned."
                  },
                  "user_email": {
                    "type": "string",
                    "description": "Reassign the owning advertiser; same rule as Create."
                  },
                  "description": {
                    "type": "string",
                    "nullable": true,
                    "description": "Send null or \"\" to clear."
                  },
                  "category": {
                    "type": "string",
                    "nullable": true,
                    "description": "Send null or \"\" to clear."
                  },
                  "preview_url": {
                    "type": "string",
                    "nullable": true,
                    "description": "Must be http(s) if present. Send null or \"\" to clear."
                  },
                  "terms": {
                    "type": "string",
                    "nullable": true,
                    "description": "Send null or \"\" to clear."
                  },
                  "allowed_traffic": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "nullable": true,
                    "description": "Send null or [] to clear."
                  },
                  "hold_days": {
                    "type": "integer",
                    "minimum": 0,
                    "maximum": 90,
                    "description": "0–90."
                  },
                  "auto_approve": {
                    "type": "boolean",
                    "description": "Optional."
                  },
                  "daily_conversion_cap": {
                    "type": "integer",
                    "nullable": true,
                    "minimum": 1,
                    "description": "Send null to clear."
                  },
                  "monthly_payout_cap_cents": {
                    "type": "integer",
                    "nullable": true,
                    "minimum": 1,
                    "description": "Send null to clear."
                  },
                  "per_affiliate_daily_cap": {
                    "type": "integer",
                    "nullable": true,
                    "minimum": 1,
                    "description": "Send null to clear."
                  }
                },
                "minProperties": 1
              },
              "example": {
                "status": "paused"
              }
            }
          }
        },
        "x-affset-permission": "write"
      },
      "delete": {
        "operationId": "offersDelete",
        "summary": "Delete an offer",
        "description": "- 409 with linked_zones while any publisher still holds a link — archive the offer (status: archived) instead if you want to keep their history attributable.\n- Deleting detaches and pauses the materialized campaign but keeps it and its event history.\n\nDocs: https://affset.com/docs#offers-delete",
        "tags": [
          "Offers"
        ],
        "parameters": [
          {
            "name": "offer_id",
            "in": "path",
            "required": true,
            "description": "Offer id.",
            "schema": {
              "type": "integer"
            },
            "example": 12
          },
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "204": {
            "description": "No Content"
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "404": {
            "$ref": "#/components/responses/Error404"
          },
          "409": {
            "$ref": "#/components/responses/Error409"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#offers-delete"
        ],
        "x-affset-permission": "write"
      }
    },
    "/api/offers/{offer_id}/link": {
      "post": {
        "operationId": "offersLink",
        "summary": "Issue a tracking link",
        "description": "- A publisher self-issues on an active public offer; apply/private offers return 403 with code apply_required for a publisher acting alone. Operators (and the offer’s advertiser) can issue for a named publisher via user_email on any visibility — including apply/private, which then becomes visible to that publisher — and this also approves a pending application for them if one exists.\n- Issuance creates a dedicated zone per (offer, publisher) pair, named \"«offer» — «publisher»\" — repeat calls return the same link and reactivate the zone if it had gone inactive. Creating the first link for a new (offer, publisher) pair counts against the zone plan allowance and can return 402; returning or reactivating an existing one does not.\n- The dedicated zone is an ordinary zone afterward — set its postback_url, traffic_back_url, or a zone-scoped payout rule same as any other zone.\n- The link host honors the tenant’s custom_api_domain.\n\nDocs: https://affset.com/docs#offers-link",
        "tags": [
          "Offers"
        ],
        "parameters": [
          {
            "name": "offer_id",
            "in": "path",
            "required": true,
            "description": "Offer id.",
            "schema": {
              "type": "integer"
            },
            "example": 12
          },
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "201": {
            "description": "Created — or 200 when a link already existed for this (offer, publisher) pair",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "offer_id": {
                      "type": "integer"
                    },
                    "zone_id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "tracking_link": {
                      "type": "string"
                    },
                    "created": {
                      "type": "boolean"
                    }
                  }
                },
                "example": {
                  "offer_id": 12,
                  "zone_id": "8e2d1e6b-2f2a-4a3e-9c8e-1c9a2f6d4b33",
                  "tracking_link": "https://api.affset.com/track/click/345/8e2d1e6b-2f2a-4a3e-9c8e-1c9a2f6d4b33",
                  "created": true
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "402": {
            "$ref": "#/components/responses/Error402"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "404": {
            "$ref": "#/components/responses/Error404"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#offers-link"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "user_email": {
                    "type": "string",
                    "description": "The publisher to issue for. Required for owner, manager, the offer’s advertiser, and publisher_manager (naming their managed publisher). A publisher self-issuing on a public offer omits it."
                  }
                }
              },
              "example": {}
            }
          }
        },
        "x-affset-permission": "write"
      }
    },
    "/api/offers/{offer_id}/apply": {
      "post": {
        "operationId": "offersApply",
        "summary": "Apply for an offer",
        "description": "Access: Publisher-side roles only. A publisher applies for itself; publisher_manager must name a managed publisher via user_email.\n\n- Only offers with visibility: \"apply\" accept applications. A public offer returns 409 with code public_offer — issue a link directly instead. A private or inactive offer reads as 404, same as any offer the caller can’t see.\n- Only one pending application may exist per offer and publisher — re-applying after a rejection or revocation creates a new history row.\n\nDocs: https://affset.com/docs#offers-apply",
        "tags": [
          "Offers"
        ],
        "parameters": [
          {
            "name": "offer_id",
            "in": "path",
            "required": true,
            "description": "Offer id.",
            "schema": {
              "type": "integer"
            },
            "example": 12
          },
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "201": {
            "description": "Created",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "integer"
                    },
                    "offer_id": {
                      "type": "integer"
                    },
                    "user_email": {
                      "type": "string",
                      "format": "email"
                    },
                    "status": {
                      "type": "string"
                    },
                    "created_at": {
                      "type": "integer"
                    }
                  }
                },
                "example": {
                  "id": 81,
                  "offer_id": 12,
                  "user_email": "publisher@example.com",
                  "status": "pending",
                  "created_at": 1787356800000
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "404": {
            "$ref": "#/components/responses/Error404"
          },
          "409": {
            "$ref": "#/components/responses/Error409"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#offers-apply"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "message": {
                    "type": "string",
                    "description": "Optional, max 1000 characters."
                  },
                  "user_email": {
                    "type": "string",
                    "description": "Required for publisher_manager — the managed publisher applying."
                  }
                }
              },
              "example": {
                "message": "Push traffic, US, 5k/day"
              }
            }
          }
        },
        "x-affset-permission": "write"
      }
    },
    "/api/offer-applications": {
      "get": {
        "operationId": "offerApplicationsList",
        "summary": "List offer applications",
        "description": "Access: Owner/manager see the tenant queue. advertiser-side roles see applications for offers they own or manage. publisher sees their own; publisher_manager sees their managed publishers’.\n\n- Publisher-facing responses omit reviewed_by — they see the outcome and when, never who decided it.\n\nDocs: https://affset.com/docs#offer-applications-list",
        "tags": [
          "Offers"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/XNamespace"
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "pending | approved | rejected | revoked.",
            "schema": {
              "type": "string",
              "enum": [
                "pending",
                "approved",
                "rejected",
                "revoked"
              ]
            },
            "example": "pending"
          },
          {
            "name": "offer_id",
            "in": "query",
            "required": false,
            "description": "Restrict to one offer.",
            "schema": {
              "type": "integer",
              "minimum": 1
            },
            "example": 12
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "1–100, default 50.",
            "schema": {
              "type": "integer",
              "default": 50,
              "minimum": 1,
              "maximum": 100
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Default 0.",
            "schema": {
              "type": "integer",
              "default": 0,
              "minimum": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "applications": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "integer"
                          },
                          "offer_id": {
                            "type": "integer"
                          },
                          "offer_name": {
                            "type": "string"
                          },
                          "user_email": {
                            "type": "string",
                            "format": "email"
                          },
                          "status": {
                            "type": "string"
                          },
                          "message": {
                            "type": "string"
                          },
                          "reviewed_by": {},
                          "reviewed_at": {},
                          "created_at": {
                            "type": "integer"
                          }
                        }
                      }
                    },
                    "pagination": {
                      "type": "object",
                      "properties": {
                        "total": {
                          "type": "integer"
                        },
                        "limit": {
                          "type": "integer"
                        },
                        "offset": {
                          "type": "integer"
                        },
                        "has_more": {
                          "type": "boolean"
                        }
                      }
                    }
                  }
                },
                "example": {
                  "applications": [
                    {
                      "id": 81,
                      "offer_id": 12,
                      "offer_name": "Sweeps SOI — US",
                      "user_email": "publisher@example.com",
                      "status": "pending",
                      "message": "Push traffic, US, 5k/day",
                      "reviewed_by": null,
                      "reviewed_at": null,
                      "created_at": 1787356800000
                    }
                  ],
                  "pagination": {
                    "total": 1,
                    "limit": 50,
                    "offset": 0,
                    "has_more": false
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#offer-applications-list"
        ],
        "x-affset-permission": "read"
      }
    },
    "/api/offer-applications/{application_id}": {
      "patch": {
        "operationId": "offerApplicationsDecide",
        "summary": "Approve, reject or revoke an application",
        "description": "Access: Owner, manager, and advertiser-side roles that own or manage the offer it targets. Publisher-side roles get 403.\n\n- Approval atomically creates or reactivates the dedicated zone and returns zone_id/tracking_link; creating a new zone counts against the plan’s zone limit and can return 402.\n- Revocation atomically pauses that zone without deleting stats history. Re-applying afterward creates a new application history row.\n- 409 when the offer is no longer active/apply, or the application isn’t in the expected starting status for that transition (pending for approved/rejected, approved for revoked).\n\nDocs: https://affset.com/docs#offer-applications-decide",
        "tags": [
          "Offers"
        ],
        "parameters": [
          {
            "name": "application_id",
            "in": "path",
            "required": true,
            "description": "Offer application id.",
            "schema": {
              "type": "integer"
            },
            "example": 81
          },
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "integer"
                    },
                    "offer_id": {
                      "type": "integer"
                    },
                    "user_email": {
                      "type": "string",
                      "format": "email"
                    },
                    "status": {
                      "type": "string"
                    },
                    "zone_id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "tracking_link": {
                      "type": "string"
                    },
                    "reviewed_at": {
                      "type": "integer"
                    }
                  }
                },
                "example": {
                  "id": 81,
                  "offer_id": 12,
                  "user_email": "publisher@example.com",
                  "status": "approved",
                  "zone_id": "8e2d1e6b-2f2a-4a3e-9c8e-1c9a2f6d4b33",
                  "tracking_link": "https://api.affset.com/track/click/345/8e2d1e6b-2f2a-4a3e-9c8e-1c9a2f6d4b33",
                  "reviewed_at": 1787360000000
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "402": {
            "$ref": "#/components/responses/Error402"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "404": {
            "$ref": "#/components/responses/Error404"
          },
          "409": {
            "$ref": "#/components/responses/Error409"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#offer-applications-decide"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "status": {
                    "type": "string",
                    "enum": [
                      "approved",
                      "rejected",
                      "revoked"
                    ],
                    "description": "Allowed transitions: pending → approved | rejected, and approved → revoked."
                  }
                },
                "required": [
                  "status"
                ]
              },
              "example": {
                "status": "approved"
              }
            }
          }
        },
        "x-affset-permission": "write"
      }
    },
    "/api/payouts/balance": {
      "get": {
        "operationId": "payoutsBalance",
        "summary": "Get a publisher’s balance",
        "description": "- locked_cents is the total of that publisher’s currently open (requested or approved) payout requests; available_cents is what a new request can draw against.\n\nDocs: https://affset.com/docs#payouts-balance",
        "tags": [
          "Payouts"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/XNamespace"
          },
          {
            "name": "user_email",
            "in": "query",
            "required": false,
            "description": "Required for owner, manager and publisher_manager — the publisher to look up. A publisher omits it (defaults to themselves).",
            "schema": {
              "type": "string"
            },
            "example": "publisher@example.com"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "user_email": {
                      "type": "string",
                      "format": "email"
                    },
                    "balance_cents": {
                      "type": "integer"
                    },
                    "locked_cents": {
                      "type": "integer"
                    },
                    "available_cents": {
                      "type": "integer"
                    },
                    "payout_min_cents": {
                      "type": "integer"
                    }
                  }
                },
                "example": {
                  "user_email": "publisher@example.com",
                  "balance_cents": 128500,
                  "locked_cents": 5000,
                  "available_cents": 123500,
                  "payout_min_cents": 5000
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#payouts-balance"
        ],
        "x-affset-permission": "read"
      }
    },
    "/api/payouts/balances": {
      "get": {
        "operationId": "payoutsBalances",
        "summary": "List every publisher’s balance",
        "description": "Access: Owner and manager only.\n\n- A paginated overview across every publisher with ledger activity — not the full user list.\n\nDocs: https://affset.com/docs#payouts-balances",
        "tags": [
          "Payouts"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/XNamespace"
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "1–100, default 50.",
            "schema": {
              "type": "integer",
              "default": 50,
              "minimum": 1,
              "maximum": 100
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Default 0.",
            "schema": {
              "type": "integer",
              "default": 0,
              "minimum": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "balances": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "user_email": {
                            "type": "string",
                            "format": "email"
                          },
                          "balance_cents": {
                            "type": "integer"
                          },
                          "locked_cents": {
                            "type": "integer"
                          },
                          "available_cents": {
                            "type": "integer"
                          }
                        }
                      }
                    },
                    "pagination": {
                      "type": "object",
                      "properties": {
                        "total": {
                          "type": "integer"
                        },
                        "limit": {
                          "type": "integer"
                        },
                        "offset": {
                          "type": "integer"
                        },
                        "has_more": {
                          "type": "boolean"
                        }
                      }
                    }
                  }
                },
                "example": {
                  "balances": [
                    {
                      "user_email": "publisher@example.com",
                      "balance_cents": 128500,
                      "locked_cents": 5000,
                      "available_cents": 123500
                    }
                  ],
                  "pagination": {
                    "total": 1,
                    "limit": 50,
                    "offset": 0,
                    "has_more": false
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#payouts-balances"
        ],
        "x-affset-permission": "read"
      }
    },
    "/api/payouts/ledger": {
      "get": {
        "operationId": "payoutsLedger",
        "summary": "Get a publisher’s ledger",
        "description": "- kind is conversion_credit | conversion_reversal | payout | adjustment. settled means the entry has been folded into a paid payout request; reversed means a later reversal cancelled it — the entry itself stays for the audit trail either way.\n- Publisher-facing responses omit created_by.\n\nDocs: https://affset.com/docs#payouts-ledger",
        "tags": [
          "Payouts"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/XNamespace"
          },
          {
            "name": "user_email",
            "in": "query",
            "required": false,
            "description": "Required for owner, manager and publisher_manager — the publisher to read. A publisher omits it (defaults to themselves).",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "1–100, default 50.",
            "schema": {
              "type": "integer",
              "default": 50,
              "minimum": 1,
              "maximum": 100
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Default 0.",
            "schema": {
              "type": "integer",
              "default": 0,
              "minimum": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "user_email": {
                      "type": "string",
                      "format": "email"
                    },
                    "entries": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "integer"
                          },
                          "amount_cents": {
                            "type": "integer"
                          },
                          "kind": {
                            "type": "string"
                          },
                          "conversion_id": {
                            "type": "string",
                            "nullable": true
                          },
                          "payout_request_id": {},
                          "settled": {
                            "type": "boolean"
                          },
                          "reversed": {
                            "type": "boolean"
                          },
                          "note": {
                            "type": "string",
                            "nullable": true
                          },
                          "created_by": {
                            "type": "string",
                            "format": "email",
                            "nullable": true
                          },
                          "created_at": {
                            "type": "integer"
                          }
                        }
                      }
                    },
                    "pagination": {
                      "type": "object",
                      "properties": {
                        "total": {
                          "type": "integer"
                        },
                        "limit": {
                          "type": "integer"
                        },
                        "offset": {
                          "type": "integer"
                        },
                        "has_more": {
                          "type": "boolean"
                        }
                      }
                    }
                  }
                },
                "example": {
                  "user_email": "publisher@example.com",
                  "entries": [
                    {
                      "id": 501,
                      "amount_cents": 3000,
                      "kind": "conversion_credit",
                      "conversion_id": "7291834650192837",
                      "payout_request_id": null,
                      "settled": false,
                      "reversed": false,
                      "note": null,
                      "created_by": null,
                      "created_at": 1787356800000
                    },
                    {
                      "id": 502,
                      "amount_cents": -2500,
                      "kind": "adjustment",
                      "conversion_id": null,
                      "payout_request_id": null,
                      "settled": false,
                      "reversed": false,
                      "note": "chargeback on conversion 991",
                      "created_by": "owner@acme-media.com",
                      "created_at": 1787360000000
                    }
                  ],
                  "pagination": {
                    "total": 2,
                    "limit": 50,
                    "offset": 0,
                    "has_more": false
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#payouts-ledger"
        ],
        "x-affset-permission": "read"
      }
    },
    "/api/payouts/adjustments": {
      "post": {
        "operationId": "payoutsAdjustments",
        "summary": "Post a manual ledger adjustment",
        "description": "Access: Owner and manager only.\n\n- Takes effect immediately — the response includes the publisher’s new balance.\n\nDocs: https://affset.com/docs#payouts-adjustments",
        "tags": [
          "Payouts"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "201": {
            "description": "Created",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "integer"
                    },
                    "user_email": {
                      "type": "string",
                      "format": "email"
                    },
                    "amount_cents": {
                      "type": "integer"
                    },
                    "kind": {
                      "type": "string"
                    },
                    "created_at": {
                      "type": "integer"
                    },
                    "balance_cents": {
                      "type": "integer"
                    },
                    "locked_cents": {
                      "type": "integer"
                    },
                    "available_cents": {
                      "type": "integer"
                    },
                    "payout_min_cents": {
                      "type": "integer"
                    }
                  }
                },
                "example": {
                  "id": 502,
                  "user_email": "publisher@example.com",
                  "amount_cents": -2500,
                  "kind": "adjustment",
                  "created_at": 1787360000000,
                  "balance_cents": 126000,
                  "locked_cents": 5000,
                  "available_cents": 121000,
                  "payout_min_cents": 5000
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#payouts-adjustments"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "user_email": {
                    "type": "string",
                    "description": "The publisher whose balance this adjusts."
                  },
                  "amount_cents": {
                    "type": "integer",
                    "minimum": -100000000,
                    "maximum": 100000000,
                    "description": "Non-zero, ±$1,000,000 cap. Negative debits the balance, positive credits it."
                  },
                  "note": {
                    "type": "string",
                    "description": "Required — an adjustment must say why."
                  }
                },
                "required": [
                  "user_email",
                  "amount_cents",
                  "note"
                ]
              },
              "example": {
                "user_email": "publisher@example.com",
                "amount_cents": -2500,
                "note": "chargeback on conversion 991"
              }
            }
          }
        },
        "x-affset-permission": "write"
      }
    },
    "/api/payout-requests": {
      "get": {
        "operationId": "payoutRequestsList",
        "summary": "List payout requests",
        "description": "- Publishers and publisher_manager see only their own (or managed) requests.\n- 422 (CSV only) if the filtered result exceeds 10,000 rows — narrow the filter and retry.\n\nDocs: https://affset.com/docs#payout-requests-list",
        "tags": [
          "Payouts"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/XNamespace"
          },
          {
            "name": "status",
            "in": "query",
            "required": false,
            "description": "requested | approved | paid | rejected.",
            "schema": {
              "type": "string",
              "enum": [
                "requested",
                "approved",
                "paid",
                "rejected"
              ]
            },
            "example": "requested"
          },
          {
            "name": "user_email",
            "in": "query",
            "required": false,
            "description": "Narrow to one publisher’s requests. Always scoped to what the caller can already see — owner/manager may use any publisher, publisher_manager only a managed one.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "format",
            "in": "query",
            "required": false,
            "description": "Owner/manager only. Returns a CSV export instead of JSON, capped at 10,000 rows — narrow status/user_email above that.",
            "schema": {
              "type": "string",
              "enum": [
                "csv"
              ]
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "1–100, default 50. Ignored when format=csv.",
            "schema": {
              "type": "integer",
              "default": 50,
              "minimum": 1,
              "maximum": 100
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Default 0. Ignored when format=csv.",
            "schema": {
              "type": "integer",
              "default": 0,
              "minimum": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "payout_requests": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "integer"
                          },
                          "user_email": {
                            "type": "string",
                            "format": "email"
                          },
                          "amount_cents": {
                            "type": "integer"
                          },
                          "status": {
                            "type": "string"
                          },
                          "method": {
                            "type": "string"
                          },
                          "note": {
                            "type": "string"
                          },
                          "created_by": {
                            "type": "string",
                            "format": "email"
                          },
                          "decided_by": {},
                          "decided_at": {},
                          "paid_at": {},
                          "created_at": {
                            "type": "integer"
                          }
                        }
                      }
                    },
                    "pagination": {
                      "type": "object",
                      "properties": {
                        "total": {
                          "type": "integer"
                        },
                        "limit": {
                          "type": "integer"
                        },
                        "offset": {
                          "type": "integer"
                        },
                        "has_more": {
                          "type": "boolean"
                        }
                      }
                    }
                  }
                },
                "example": {
                  "payout_requests": [
                    {
                      "id": 91,
                      "user_email": "publisher@example.com",
                      "amount_cents": 50000,
                      "status": "requested",
                      "method": "USDT TRC-20 T…",
                      "note": "monthly cashout",
                      "created_by": "publisher@example.com",
                      "decided_by": null,
                      "decided_at": null,
                      "paid_at": null,
                      "created_at": 1787356800000
                    }
                  ],
                  "pagination": {
                    "total": 1,
                    "limit": 50,
                    "offset": 0,
                    "has_more": false
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "422": {
            "$ref": "#/components/responses/Error422"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#payout-requests-list"
        ],
        "x-affset-permission": "read"
      },
      "post": {
        "operationId": "payoutRequestsCreate",
        "summary": "Create a payout request",
        "description": "Access: publisher (for themselves), publisher_manager (user_email required — a managed publisher), owner and manager (user_email required — any publisher).\n\n- A publisher-side request must clear the tenant’s payout_min_cents (default 5000, see Tenant settings) — an operator opening one on a publisher’s behalf may close out any balance regardless of the minimum.\n- Only one open (requested or approved) request may exist per publisher — a second attempt returns 409 with code open_request_exists. An amount exceeding the publisher’s available balance returns 409 with code insufficient_balance.\n- The 201 response omits created_by/decided_by for publisher-side callers.\n\nDocs: https://affset.com/docs#payout-requests-create",
        "tags": [
          "Payouts"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "201": {
            "description": "Created",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "integer"
                    },
                    "user_email": {
                      "type": "string",
                      "format": "email"
                    },
                    "amount_cents": {
                      "type": "integer"
                    },
                    "status": {
                      "type": "string"
                    },
                    "method": {
                      "type": "string"
                    },
                    "note": {
                      "type": "string"
                    },
                    "decided_at": {},
                    "paid_at": {},
                    "created_at": {
                      "type": "integer"
                    }
                  }
                },
                "example": {
                  "id": 91,
                  "user_email": "publisher@example.com",
                  "amount_cents": 50000,
                  "status": "requested",
                  "method": "USDT TRC-20 T…",
                  "note": "monthly cashout",
                  "decided_at": null,
                  "paid_at": null,
                  "created_at": 1787356800000
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "409": {
            "$ref": "#/components/responses/Error409"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#payout-requests-create"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "amount_cents": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 100000000,
                    "description": "Positive, up to $1,000,000."
                  },
                  "method": {
                    "type": "string",
                    "description": "Where the payout should go, e.g. \"USDT TRC-20 T…\"."
                  },
                  "note": {
                    "type": "string",
                    "description": "Optional."
                  },
                  "user_email": {
                    "type": "string",
                    "description": "Required for owner, manager and publisher_manager — the publisher this request is for. A publisher omits it (defaults to themselves)."
                  }
                },
                "required": [
                  "amount_cents",
                  "method"
                ]
              },
              "example": {
                "amount_cents": 50000,
                "method": "USDT TRC-20 T…",
                "note": "monthly cashout"
              }
            }
          }
        },
        "x-affset-permission": "write"
      }
    },
    "/api/payout-requests/{payout_request_id}": {
      "patch": {
        "operationId": "payoutRequestsDecide",
        "summary": "Decide a payout request",
        "description": "Access: Owner and manager only.\n\n- Marking a request paid re-checks that the publisher’s balance still covers it — 409 with code insufficient_balance if not (e.g. a chargeback landed since approval) — and settles the corresponding ledger entries.\n- 404 for an unknown id; 409 when the request isn’t in the expected starting status (requested for approved, requested or approved for rejected, approved for paid).\n\nDocs: https://affset.com/docs#payout-requests-decide",
        "tags": [
          "Payouts"
        ],
        "parameters": [
          {
            "name": "payout_request_id",
            "in": "path",
            "required": true,
            "description": "Payout request id.",
            "schema": {
              "type": "integer"
            },
            "example": 91
          },
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "integer"
                    },
                    "user_email": {
                      "type": "string",
                      "format": "email"
                    },
                    "amount_cents": {
                      "type": "integer"
                    },
                    "status": {
                      "type": "string"
                    },
                    "method": {
                      "type": "string"
                    },
                    "note": {
                      "type": "string"
                    },
                    "created_by": {
                      "type": "string",
                      "format": "email"
                    },
                    "decided_by": {
                      "type": "string",
                      "format": "email"
                    },
                    "decided_at": {
                      "type": "integer"
                    },
                    "paid_at": {
                      "type": "integer"
                    },
                    "created_at": {
                      "type": "integer"
                    }
                  }
                },
                "example": {
                  "id": 91,
                  "user_email": "publisher@example.com",
                  "amount_cents": 50000,
                  "status": "paid",
                  "method": "USDT TRC-20 T…",
                  "note": "sent 2026-08-24",
                  "created_by": "publisher@example.com",
                  "decided_by": "owner@acme-media.com",
                  "decided_at": 1787360000000,
                  "paid_at": 1787360000000,
                  "created_at": 1787356800000
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "404": {
            "$ref": "#/components/responses/Error404"
          },
          "409": {
            "$ref": "#/components/responses/Error409"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#payout-requests-decide"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "status": {
                    "type": "string",
                    "enum": [
                      "approved",
                      "rejected",
                      "paid"
                    ],
                    "description": "Allowed transitions: requested → approved | rejected, and approved → paid | rejected."
                  },
                  "note": {
                    "type": "string",
                    "description": "Optional."
                  }
                },
                "required": [
                  "status"
                ]
              },
              "example": {
                "status": "paid",
                "note": "sent 2026-08-24"
              }
            }
          }
        },
        "x-affset-permission": "write"
      }
    },
    "/api/payout-requests/bulk": {
      "post": {
        "operationId": "payoutRequestsBulkDecide",
        "summary": "Bulk-decide payout requests",
        "description": "Access: Owner and manager only.\n\n- Each id is decided independently — one bad row (already-decided, insufficient balance) never blocks the rest of the batch. applied counts only the ones that actually changed.\n- Duplicate ids in the same call are decided once.\n\nDocs: https://affset.com/docs#payout-requests-bulk-decide",
        "tags": [
          "Payouts"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string"
                    },
                    "applied": {
                      "type": "integer"
                    },
                    "results": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "integer"
                          },
                          "ok": {
                            "type": "boolean"
                          },
                          "user_email": {
                            "type": "string",
                            "format": "email"
                          },
                          "amount_cents": {
                            "type": "integer"
                          },
                          "status": {
                            "type": "string"
                          },
                          "decided_by": {
                            "type": "string",
                            "format": "email"
                          },
                          "decided_at": {
                            "type": "integer"
                          },
                          "error": {
                            "type": "string"
                          }
                        }
                      }
                    }
                  }
                },
                "example": {
                  "status": "approved",
                  "applied": 2,
                  "results": [
                    {
                      "id": 91,
                      "ok": true,
                      "user_email": "publisher@example.com",
                      "amount_cents": 50000,
                      "status": "approved",
                      "decided_by": "owner@acme-media.com",
                      "decided_at": 1787360000000
                    },
                    {
                      "id": 92,
                      "ok": false,
                      "error": "Cannot transition a 'paid' request to 'approved'"
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#payout-requests-bulk-decide"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "ids": {
                    "type": "array",
                    "minItems": 1,
                    "maxItems": 100,
                    "items": {
                      "type": "integer",
                      "minimum": 1
                    },
                    "description": "1–100 positive integer payout request ids."
                  },
                  "status": {
                    "type": "string",
                    "enum": [
                      "approved",
                      "rejected",
                      "paid"
                    ],
                    "description": "Same statuses as Decide a payout request, applied to every id."
                  },
                  "note": {
                    "type": "string",
                    "description": "Optional; applied to every id decided."
                  }
                },
                "required": [
                  "ids",
                  "status"
                ]
              },
              "example": {
                "ids": [
                  91,
                  92,
                  93
                ],
                "status": "approved"
              }
            }
          }
        },
        "x-affset-permission": "write"
      }
    },
    "/api/targeting-rule-types": {
      "get": {
        "operationId": "targetingCatalog",
        "summary": "Catalog of targeting rule types",
        "description": "- ⚠️ capping, weekdays and hours are accepted and stored, but /serve never evaluates them — writing them has no effect on delivery. Use unique_users for frequency capping instead.\n- geo, os and browser are matched exactly and case-sensitively. An unmatched whitelist value silently stops delivery rather than erroring anywhere.\n\nDocs: https://affset.com/docs#targeting-catalog",
        "tags": [
          "Targeting"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "targeting_rule_types": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "integer"
                          },
                          "name": {
                            "type": "string"
                          },
                          "description": {
                            "type": "string"
                          }
                        }
                      }
                    }
                  }
                },
                "example": {
                  "targeting_rule_types": [
                    {
                      "id": 1,
                      "name": "geo",
                      "description": "Comma-separated ISO-3166 alpha-2 country codes, matched against the request’s country."
                    },
                    {
                      "id": 2,
                      "name": "device_type",
                      "description": "desktop, mobile or tablet, comma-separated."
                    },
                    {
                      "id": 3,
                      "name": "capping",
                      "description": "Stored only — not evaluated by /serve."
                    },
                    {
                      "id": 4,
                      "name": "zone_id",
                      "description": "Comma-separated zone IDs. This is how zone blacklists/whitelists work."
                    },
                    {
                      "id": 5,
                      "name": "os",
                      "description": "Comma-separated OS names, exact match, e.g. Android, iOS, Windows."
                    },
                    {
                      "id": 6,
                      "name": "browser",
                      "description": "Comma-separated browser names, exact match, e.g. Chrome, Safari."
                    },
                    {
                      "id": 7,
                      "name": "weekdays",
                      "description": "Stored only — not evaluated by /serve."
                    },
                    {
                      "id": 8,
                      "name": "hours",
                      "description": "Stored only — not evaluated by /serve."
                    },
                    {
                      "id": 9,
                      "name": "unique_users",
                      "description": "A single \"visits/hours\" value, e.g. 1/24 — frequency capping."
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#targeting-catalog"
        ],
        "x-affset-permission": "read"
      }
    },
    "/api/campaigns/{campaign_id}/targeting_rules": {
      "get": {
        "operationId": "targetingList",
        "summary": "List a campaign’s targeting rules",
        "description": "Docs: https://affset.com/docs#targeting-list",
        "tags": [
          "Targeting"
        ],
        "parameters": [
          {
            "name": "campaign_id",
            "in": "path",
            "required": true,
            "description": "Campaign id.",
            "schema": {
              "type": "integer"
            },
            "example": 42
          },
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "targeting_rules": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "integer"
                          },
                          "targeting_rule_type_id": {
                            "type": "integer"
                          },
                          "targeting_method": {
                            "type": "string"
                          },
                          "rule": {
                            "type": "string"
                          }
                        }
                      }
                    }
                  }
                },
                "example": {
                  "targeting_rules": [
                    {
                      "id": 501,
                      "targeting_rule_type_id": 1,
                      "targeting_method": "whitelist",
                      "rule": "BR,MX"
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "404": {
            "$ref": "#/components/responses/Error404"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#targeting-list"
        ],
        "x-affset-permission": "read"
      },
      "post": {
        "operationId": "targetingSync",
        "summary": "Replace a campaign’s targeting rules",
        "description": "- ⚠️ This replaces the whole set for the campaign. Any existing rule whose id is left out of the body gets deleted. Always GET the current list first, then send it back with your change folded in — see the recipes below.\n\nDocs: https://affset.com/docs#targeting-sync",
        "tags": [
          "Targeting"
        ],
        "parameters": [
          {
            "name": "campaign_id",
            "in": "path",
            "required": true,
            "description": "Campaign id.",
            "schema": {
              "type": "integer"
            },
            "example": 42
          },
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "properties": {
                      "id": {
                        "type": "integer"
                      },
                      "targeting_rule_type_id": {
                        "type": "integer"
                      },
                      "targeting_method": {
                        "type": "string"
                      },
                      "rule": {
                        "type": "string"
                      }
                    }
                  }
                },
                "example": [
                  {
                    "id": 501,
                    "targeting_rule_type_id": 1,
                    "targeting_method": "whitelist",
                    "rule": "BR,MX"
                  },
                  {
                    "id": 502,
                    "targeting_rule_type_id": 4,
                    "targeting_method": "blacklist",
                    "rule": "550e8400-e29b-41d4-a716-446655440000"
                  }
                ]
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "404": {
            "$ref": "#/components/responses/Error404"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#targeting-sync"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/TargetingRuleInput"
                },
                "description": "targeting_method is \"whitelist\" or \"blacklist\"."
              },
              "example": [
                {
                  "id": 501,
                  "targeting_rule_type_id": 1,
                  "targeting_method": "whitelist",
                  "rule": "BR,MX"
                },
                {
                  "targeting_rule_type_id": 4,
                  "targeting_method": "blacklist",
                  "rule": "550e8400-e29b-41d4-a716-446655440000"
                }
              ]
            }
          }
        },
        "x-affset-permission": "write"
      }
    },
    "/api/conversions": {
      "get": {
        "operationId": "conversionsList",
        "summary": "List conversions",
        "description": "- ad_event_id and click_id are returned as strings — they’re 64-bit IDs that don’t fit exactly in a JS number.\n- No campaign, zone or date filters — just pagination and sort. This is the record-level audit trail; use Stats for aggregated, filterable reporting.\n- payload is a JSON-encoded string of the conversion pixel’s query parameters (except click_id), and may also include postback status fields. Anyone who can fire a pixel controls those values — treat them as untrusted data if you feed them into anything automated.\n- Same role-based redaction as Stats: publisher-side roles don’t see spend, advertiser-side roles don’t see payout.\n\nDocs: https://affset.com/docs#conversions-list",
        "tags": [
          "Conversions"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/XNamespace"
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "1–100, default 20.",
            "schema": {
              "type": "integer",
              "default": 20,
              "minimum": 1,
              "maximum": 100
            },
            "example": 5
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "description": "Default 0.",
            "schema": {
              "type": "integer",
              "default": 0,
              "minimum": 0
            }
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "description": "created_at (default) | ad_event_id | click_id.",
            "schema": {
              "type": "string",
              "enum": [
                "created_at",
                "ad_event_id",
                "click_id"
              ],
              "default": "created_at"
            }
          },
          {
            "name": "order",
            "in": "query",
            "required": false,
            "description": "asc | desc (default).",
            "schema": {
              "type": "string",
              "enum": [
                "asc",
                "desc"
              ],
              "default": "desc"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "conversions": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "ad_event_id": {
                            "type": "string"
                          },
                          "click_id": {
                            "type": "string"
                          },
                          "payload": {
                            "type": "string"
                          },
                          "spend": {
                            "type": "integer"
                          },
                          "payout": {
                            "type": "integer"
                          },
                          "source_click_id": {
                            "type": "string"
                          },
                          "sub1": {
                            "type": "string"
                          },
                          "sub2": {},
                          "sub3": {},
                          "sub4": {},
                          "sub5": {},
                          "created_at": {
                            "type": "integer"
                          }
                        }
                      }
                    },
                    "pagination": {
                      "type": "object",
                      "properties": {
                        "total": {
                          "type": "integer"
                        },
                        "limit": {
                          "type": "integer"
                        },
                        "offset": {
                          "type": "integer"
                        },
                        "has_more": {
                          "type": "boolean"
                        }
                      }
                    }
                  }
                },
                "example": {
                  "conversions": [
                    {
                      "ad_event_id": "7291834650192837",
                      "click_id": "7291834612345678",
                      "payload": "{\"type\":\"deposit\",\"value\":\"49\"}",
                      "spend": 0,
                      "payout": 3,
                      "source_click_id": "abc123",
                      "sub1": "richads",
                      "sub2": null,
                      "sub3": null,
                      "sub4": null,
                      "sub5": null,
                      "created_at": 1753747200000
                    }
                  ],
                  "pagination": {
                    "total": 3,
                    "limit": 5,
                    "offset": 0,
                    "has_more": false
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#conversions-list"
        ],
        "x-affset-permission": "read"
      }
    },
    "/api/conversions/{ad_event_id}": {
      "delete": {
        "operationId": "conversionsDelete",
        "summary": "Delete a conversion",
        "description": "Access: Owner only.\n\n- Deletes the conversion row and its matching conversion event. The originating click remains in the event history.\n\nDocs: https://affset.com/docs#conversions-delete",
        "tags": [
          "Conversions"
        ],
        "parameters": [
          {
            "name": "ad_event_id",
            "in": "path",
            "required": true,
            "description": "Conversion event id — a 64-bit integer serialized as a string.",
            "schema": {
              "type": "string"
            },
            "example": "7291834650192837"
          },
          {
            "$ref": "#/components/parameters/XNamespace"
          }
        ],
        "responses": {
          "204": {
            "description": "No Content"
          },
          "400": {
            "$ref": "#/components/responses/Error400"
          },
          "401": {
            "$ref": "#/components/responses/Error401"
          },
          "403": {
            "$ref": "#/components/responses/Error403"
          },
          "404": {
            "$ref": "#/components/responses/Error404"
          },
          "500": {
            "$ref": "#/components/responses/Error500"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#conversions-delete"
        ],
        "x-affset-permission": "write"
      }
    },
    "/api/public/auth/find-workspaces": {
      "post": {
        "operationId": "authFindWorkspaces",
        "summary": "Email one sign-in link per workspace the address belongs to",
        "description": "- Enumeration-safe by construction: the lookup and the send happen after the response is returned, so neither the body nor the timing reveals whether the address belongs anywhere.\n- One workspace → the same email request-link sends. Several → one email, one namespace-bound link per workspace (max 20; the email says when there are more). None → an email saying no workspace matched.\n- Each link redeems through verify-link in its own workspace only. Throttled per address to one email per minute (silently).\n\nDocs: https://affset.com/docs#auth-find-workspaces",
        "tags": [
          "Sign-in"
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "OK — always the same body — invalid, unknown, 1 or many workspaces",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "message": {
                      "type": "string"
                    }
                  }
                },
                "example": {
                  "ok": true,
                  "message": "If the address belongs to any workspace, an email with your sign-in links is on its way."
                }
              }
            }
          },
          "429": {
            "description": "HTTP 429 — per-IP bucket (5 / 15 min), with Retry-After"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#auth-find-workspaces"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "email": {
                    "type": "string",
                    "description": "The address to look up. Nothing else — the workspaces are listed only in the email."
                  }
                },
                "required": [
                  "email"
                ]
              },
              "example": {
                "email": "owner@my-company.com"
              }
            }
          }
        },
        "security": []
      }
    },
    "/api/public/auth/request-link": {
      "post": {
        "operationId": "authRequestLink",
        "summary": "Email a single-use sign-in link for one workspace",
        "description": "- Links work once and expire after 15 minutes. At most one link per address and workspace per minute; repeats inside the window are dropped silently.\n\nDocs: https://affset.com/docs#auth-request-link",
        "tags": [
          "Sign-in"
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "OK — always the same body — valid, unknown or throttled address",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "message": {
                      "type": "string"
                    }
                  }
                },
                "example": {
                  "ok": true,
                  "message": "If the address matches an account, a sign-in link has been sent."
                }
              }
            }
          },
          "429": {
            "description": "HTTP 429 — per-IP bucket (10 / 15 min), with Retry-After"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#auth-request-link"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "email": {
                    "type": "string",
                    "description": "Member address in that workspace."
                  },
                  "namespace": {
                    "type": "string",
                    "description": "The workspace — the first label of {namespace}.affset.com."
                  }
                },
                "required": [
                  "email",
                  "namespace"
                ]
              },
              "example": {
                "email": "owner@my-company.com",
                "namespace": "my-company"
              }
            }
          }
        },
        "security": []
      }
    },
    "/api/public/auth/verify-link": {
      "post": {
        "operationId": "authVerifyLink",
        "summary": "Redeem a sign-in link for a 30-day browser session",
        "description": "- The session token is used exactly like an API key: Authorization: Bearer <token> plus X-Namespace. It is bound to the workspace the link was issued for.\n- Redeeming the tenant owner’s link also marks the owner email verified.\n\nDocs: https://affset.com/docs#auth-verify-link",
        "tags": [
          "Sign-in"
        ],
        "parameters": [],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "token": {
                      "type": "string"
                    },
                    "namespace": {
                      "type": "string"
                    },
                    "expires_at": {
                      "type": "integer"
                    }
                  }
                },
                "example": {
                  "token": "browser-session-token",
                  "namespace": "my-company",
                  "expires_at": 1721347200000
                }
              }
            }
          },
          "400": {
            "description": "HTTP 400 — invalid, already used or expired — {error, code}"
          },
          "429": {
            "description": "HTTP 429 — per-IP bucket (30 / 15 min)"
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#auth-verify-link"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "token": {
                    "type": "string",
                    "description": "The token from the emailed link’s ?token= parameter."
                  }
                },
                "required": [
                  "token"
                ]
              },
              "example": {
                "token": "token-from-the-login-link"
              }
            }
          }
        },
        "security": []
      }
    },
    "/serve/{zone_id}": {
      "get": {
        "operationId": "adServe",
        "summary": "Entry point you give a traffic source",
        "description": "- Picks one eligible campaign from the zone’s active, targeting-matched campaigns and redirects to /track/click for it.\n- No eligible campaign → redirects to the zone’s traffic_back_url if set, otherwise a plain-text 404.\n- source_click_id and sub1–sub5 carry forward to /track/click. cost is recorded on the /serve impression and deliberately not forwarded, so it is counted once; other query parameters are dropped.\n- Geo/device/OS/browser targeting is enforced here. It is not enforced on a direct /track/click link.\n\nDocs: https://affset.com/docs#ad-serve",
        "tags": [
          "Ad serving"
        ],
        "parameters": [
          {
            "name": "zone_id",
            "in": "path",
            "required": true,
            "description": "Zone id.",
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "example": "550e8400-e29b-41d4-a716-446655440000"
          },
          {
            "name": "source_click_id",
            "in": "query",
            "required": false,
            "description": "Your source’s click id. Legacy alias: sub_id.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sub1",
            "in": "query",
            "required": false,
            "description": "Passed through to the click and any conversions on it.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sub2",
            "in": "query",
            "required": false,
            "description": "Passed through to the click and any conversions on it.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sub3",
            "in": "query",
            "required": false,
            "description": "Passed through to the click and any conversions on it.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sub4",
            "in": "query",
            "required": false,
            "description": "Passed through to the click and any conversions on it.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sub5",
            "in": "query",
            "required": false,
            "description": "Passed through to the click and any conversions on it.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "cost",
            "in": "query",
            "required": false,
            "description": "Media cost for this impression — see the note below. Plain unsigned decimal.",
            "schema": {
              "type": "number",
              "minimum": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK — HTML redirect page (JS + meta refresh) when the tenant’s redirect_method is html.",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "302": {
            "description": "Found — or 200 with an HTML redirect, per redirect_method",
            "headers": {
              "Location": {
                "description": "Redirect target.",
                "schema": {
                  "type": "string",
                  "format": "uri"
                }
              }
            }
          },
          "404": {
            "description": "Unknown zone, campaign or click — or nothing to serve and no traffic_back_url. Plain text, not JSON.",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#ad-serve"
        ],
        "security": []
      }
    },
    "/track/click/{campaign_id}/{zone_id}": {
      "get": {
        "operationId": "adTrack",
        "summary": "Direct tracking link for one campaign",
        "description": "- Records the click and, for CPM campaigns, computes spend as rate/1000 — CPA campaigns accrue spend on conversion instead.\n- Not geo/targeting gated — only /serve enforces targeting.\n- Macros in redirect_url: {click_id}, {zone_id}, {source_click_id} (alias {aff_sub_id}), {sub1}…{sub5}. Values are percent-encoded on substitution. A macro with nothing to fill stays as literal text.\n\nDocs: https://affset.com/docs#ad-track",
        "tags": [
          "Ad serving"
        ],
        "parameters": [
          {
            "name": "campaign_id",
            "in": "path",
            "required": true,
            "description": "Campaign id.",
            "schema": {
              "type": "integer"
            },
            "example": 42
          },
          {
            "name": "zone_id",
            "in": "path",
            "required": true,
            "description": "Zone id.",
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "example": "550e8400-e29b-41d4-a716-446655440000"
          },
          {
            "name": "source_click_id",
            "in": "query",
            "required": false,
            "description": "Legacy alias: sub_id.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sub1",
            "in": "query",
            "required": false,
            "description": "Stored on the click and attributed to later conversions.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sub2",
            "in": "query",
            "required": false,
            "description": "Stored on the click and attributed to later conversions.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sub3",
            "in": "query",
            "required": false,
            "description": "Stored on the click and attributed to later conversions.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sub4",
            "in": "query",
            "required": false,
            "description": "Stored on the click and attributed to later conversions.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sub5",
            "in": "query",
            "required": false,
            "description": "Stored on the click and attributed to later conversions.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "cost",
            "in": "query",
            "required": false,
            "description": "Only send this here or on /serve for a given stream, never both — sending it to both double-counts. Plain unsigned decimal.",
            "schema": {
              "type": "number",
              "minimum": 0
            }
          }
        ],
        "responses": {
          "302": {
            "description": "Found — to the campaign’s redirect_url, macros expanded",
            "headers": {
              "Location": {
                "description": "Redirect target.",
                "schema": {
                  "type": "string",
                  "format": "uri"
                }
              }
            }
          },
          "404": {
            "description": "Unknown zone, campaign or click — or nothing to serve and no traffic_back_url. Plain text, not JSON.",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#ad-track"
        ],
        "security": []
      }
    },
    "/px/{click_id}": {
      "get": {
        "operationId": "adPixel",
        "summary": "Conversion pixel (into Affset)",
        "description": "- Send Accept: application/json for a JSON response. Other callers receive the 1×1 GIF used by browser pixels.\n- After a non-silent conversion, Affset GETs the zone’s postback_url (affiliate passback) with macros {payout}, {source_click_id} (alias {sub_id}), {sub1}…{sub5}. Skipped when silent or when the zone has no postback_url.\n- Payout comes from campaign payout rules (zone-specific → global → $0), not from a query param on /px.\n- Any other query parameter: Stored in the conversion payload (except click_id). source_click_id here cannot rewrite the click’s token used for the affiliate postback.\n\nDocs: https://affset.com/docs#ad-pixel",
        "tags": [
          "Ad serving"
        ],
        "parameters": [
          {
            "name": "click_id",
            "in": "path",
            "required": true,
            "description": "Affset click id — a 64-bit integer serialized as a string, from the {click_id} macro.",
            "schema": {
              "type": "string"
            },
            "example": "7291834612345678"
          },
          {
            "name": "type",
            "in": "query",
            "required": false,
            "description": "Goal label. When the campaign has payout_goal_type set, only an exact type= match accrues spend/payout; others still record at $0.",
            "schema": {
              "type": "string"
            },
            "example": "deposit"
          },
          {
            "name": "sub1",
            "in": "query",
            "required": false,
            "description": "Fill empty slots from the click only — cannot overwrite values already stored on the click.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sub2",
            "in": "query",
            "required": false,
            "description": "Fill empty slots from the click only — cannot overwrite values already stored on the click.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sub3",
            "in": "query",
            "required": false,
            "description": "Fill empty slots from the click only — cannot overwrite values already stored on the click.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sub4",
            "in": "query",
            "required": false,
            "description": "Fill empty slots from the click only — cannot overwrite values already stored on the click.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sub5",
            "in": "query",
            "required": false,
            "description": "Fill empty slots from the click only — cannot overwrite values already stored on the click.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK — 1×1 GIF by default, or {\"status\":\"ok\"} for a JSON-flavored request",
            "content": {
              "image/gif": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              },
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string"
                    }
                  }
                },
                "example": {
                  "status": "ok"
                }
              }
            }
          },
          "404": {
            "description": "Unknown zone, campaign or click — or nothing to serve and no traffic_back_url. Plain text, not JSON.",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#ad-pixel"
        ],
        "security": []
      }
    },
    "/px": {
      "get": {
        "operationId": "adPixelQuery",
        "summary": "Conversion pixel (into Affset) — query form",
        "description": "- Send Accept: application/json for a JSON response. Other callers receive the 1×1 GIF used by browser pixels.\n- After a non-silent conversion, Affset GETs the zone’s postback_url (affiliate passback) with macros {payout}, {source_click_id} (alias {sub_id}), {sub1}…{sub5}. Skipped when silent or when the zone has no postback_url.\n- Payout comes from campaign payout rules (zone-specific → global → $0), not from a query param on /px.\n- Any other query parameter: Stored in the conversion payload (except click_id). source_click_id here cannot rewrite the click’s token used for the affiliate postback.\n\nDocs: https://affset.com/docs#ad-pixel",
        "tags": [
          "Ad serving"
        ],
        "parameters": [
          {
            "name": "click_id",
            "in": "query",
            "required": true,
            "description": "Affset click id from the offer redirect’s {click_id}.",
            "schema": {
              "type": "string"
            },
            "example": "7291834612345678"
          },
          {
            "name": "type",
            "in": "query",
            "required": false,
            "description": "Goal label. When the campaign has payout_goal_type set, only an exact type= match accrues spend/payout; others still record at $0.",
            "schema": {
              "type": "string"
            },
            "example": "deposit"
          },
          {
            "name": "sub1",
            "in": "query",
            "required": false,
            "description": "Fill empty slots from the click only — cannot overwrite values already stored on the click.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sub2",
            "in": "query",
            "required": false,
            "description": "Fill empty slots from the click only — cannot overwrite values already stored on the click.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sub3",
            "in": "query",
            "required": false,
            "description": "Fill empty slots from the click only — cannot overwrite values already stored on the click.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sub4",
            "in": "query",
            "required": false,
            "description": "Fill empty slots from the click only — cannot overwrite values already stored on the click.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sub5",
            "in": "query",
            "required": false,
            "description": "Fill empty slots from the click only — cannot overwrite values already stored on the click.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK — 1×1 GIF by default, or {\"status\":\"ok\"} for a JSON-flavored request",
            "content": {
              "image/gif": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              },
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string"
                    }
                  }
                },
                "example": {
                  "status": "ok"
                }
              }
            }
          },
          "404": {
            "description": "Unknown zone, campaign or click — or nothing to serve and no traffic_back_url. Plain text, not JSON.",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        },
        "x-affset-docs": [
          "https://affset.com/docs#ad-pixel"
        ],
        "security": []
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Tenant API key or session token, sent together with the X-Namespace header. Key permissions: read (GET) and write (POST, PUT, PATCH, DELETE) — see x-affset-permission on each operation. Issue least-privilege keys from the dashboard Team page or POST /api/api-keys?type=api-key."
      }
    },
    "parameters": {
      "XNamespace": {
        "name": "X-Namespace",
        "in": "header",
        "required": true,
        "description": "The tenant the bearer token belongs to. A token used with another namespace is rejected with 401.",
        "schema": {
          "type": "string"
        },
        "example": "acme-media"
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "string",
            "description": "Human-readable message."
          },
          "code": {
            "type": "string",
            "description": "Machine-readable code on some errors, e.g. EMAIL_VERIFICATION_REQUIRED or PLAN_LIMIT_REACHED."
          }
        }
      },
      "PlanLimitError": {
        "description": "Returned with HTTP 402 when an action would exceed the plan.",
        "type": "object",
        "required": [
          "error",
          "code",
          "dimension",
          "limit",
          "current",
          "plan_id"
        ],
        "properties": {
          "error": {
            "type": "string",
            "description": "Human-readable message."
          },
          "code": {
            "type": "string",
            "enum": [
              "PLAN_LIMIT_REACHED"
            ]
          },
          "dimension": {
            "type": "string",
            "description": "Which plan dimension is exhausted, e.g. campaigns, zones, team, api_keys, custom_api_domain."
          },
          "limit": {
            "type": "integer"
          },
          "current": {
            "type": "integer"
          },
          "plan_id": {
            "type": "string"
          },
          "min_plan_id": {
            "type": "string",
            "nullable": true,
            "description": "Cheapest plan that would allow the action."
          }
        },
        "example": {
          "error": "Plan limit reached for campaigns",
          "code": "PLAN_LIMIT_REACHED",
          "dimension": "campaigns",
          "limit": 10,
          "current": 10,
          "plan_id": "free",
          "min_plan_id": "starter"
        }
      },
      "TargetingRuleInput": {
        "type": "object",
        "required": [
          "targeting_rule_type_id",
          "targeting_method",
          "rule"
        ],
        "properties": {
          "id": {
            "type": "integer",
            "description": "Existing rule id to keep; omit for a new rule."
          },
          "targeting_rule_type_id": {
            "type": "integer",
            "description": "From GET /api/targeting-rule-types."
          },
          "targeting_method": {
            "type": "string",
            "enum": [
              "whitelist",
              "blacklist"
            ]
          },
          "rule": {
            "type": "string",
            "description": "Comma-separated values as described for the rule type."
          }
        }
      },
      "OfferGoalInput": {
        "type": "object",
        "required": [
          "name",
          "conversion_type",
          "payout_cents",
          "revenue_cents"
        ],
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 100
          },
          "conversion_type": {
            "type": "string",
            "pattern": "^[A-Za-z0-9_.-]{1,64}$",
            "description": "Must equal the conversion pixel’s type= parameter and be unique within the offer."
          },
          "payout_cents": {
            "type": "integer",
            "minimum": 0,
            "maximum": 99999999,
            "description": "What the affiliate earns for this goal, in integer cents."
          },
          "revenue_cents": {
            "type": "integer",
            "minimum": 0,
            "maximum": 99999999,
            "description": "What the advertiser pays for this goal, in integer cents."
          },
          "sort": {
            "type": "integer",
            "minimum": 0,
            "description": "Display order. Defaults to insertion order."
          }
        }
      }
    },
    "responses": {
      "Error400": {
        "description": "Bad Request — missing header, parameter, or body, or an invalid value.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Error401": {
        "description": "Unauthorized — invalid or expired key, or X-Namespace doesn't match it.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Error402": {
        "description": "Payment Required — you’re at a plan limit. See below.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/PlanLimitError"
            }
          }
        }
      },
      "Error403": {
        "description": "Forbidden — your role or permissions don't allow this. Also used to mask ownership on writes.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Error404": {
        "description": "Not Found — the resource doesn't exist, or isn't visible to your role.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Error409": {
        "description": "Conflict — something with the same identity already exists.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Error422": {
        "description": "Unprocessable Entity — the request is well-formed but cannot be processed (e.g. a CSV export that exceeds the row cap).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Error500": {
        "description": "Internal Server Error — something went wrong on our end.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    }
  },
  "x-affset": {
    "docs": "https://affset.com/docs",
    "api_reference_markdown": "https://affset.com/api-reference.md",
    "api_reference_json": "https://affset.com/api-reference.json",
    "llms_txt": "https://affset.com/llms.txt",
    "permissions": {
      "read": {
        "methods": [
          "GET"
        ],
        "description": "GET endpoints — list and read resources, stats, and conversions."
      },
      "write": {
        "methods": [
          "POST",
          "PUT",
          "PATCH",
          "DELETE"
        ],
        "description": "POST, PUT, PATCH and DELETE — create, update, delete, rotate, revoke."
      }
    },
    "mcp": {
      "endpoint": "https://mcp.affset.com/mcp",
      "oauth_protected_resource_metadata": "https://mcp.affset.com/.well-known/oauth-protected-resource",
      "oauth_authorization_server_metadata": "https://oauth.affset.com/.well-known/oauth-authorization-server",
      "source": "https://github.com/affset/mcp",
      "scopes": {
        "read": {
          "permissions": [
            "read"
          ],
          "description": "Read-only tool set — every tool that writes is stripped from the session."
        },
        "full": {
          "permissions": [
            "read",
            "write"
          ],
          "description": "Every tool, backed by a read+write key. Only offered to roles that can write."
        }
      }
    }
  }
}
