---
openapi: 3.0.1
info:
  title: Little Outreach API
  description: |-
    **Base path:** all endpoints are under `/api/v1` on your deployment host (same origin as the web app unless configured otherwise).

    **What it does:** JSON API for directory data — **people**, **organizations**, **memberships**, **places**, **name aliases**, **source references**, plus **unified search** (Searchkick with SQL fallback) and **zero-result search** task tracking for editors.

    **Advertisers (alias):** `GET|POST|PATCH /api/v1/advertisers` and `GET …/advertisers/:public_id` and `GET …/advertisers/:public_id/members` mirror **organizations** routes. Responses add **`advertisers`** (list) and **`advertiser`** (single) alongside `organizations` / `organization`. Writes may use a nested **`advertiser`** body instead of **`organization`**.

    **Authentication:** send your per-user API key on every request, either as `Authorization: Bearer <token>` or header `X-Api-Key: <token>`. Obtain a key from the API key page (`/account/api-key`) when signed in. Requests without a valid key return **401 Unauthorized**.

    **Credits & billing:** each successful `/api/v1` call uses **one credit** (non-admin keys). New accounts start with **1,000** free credits. When credits reach zero, the API returns **402 Payment Required** with `error: insufficient_credits`. Response headers include **`X-API-Credits-Remaining`** (or `unlimited` for admins). Use **GET /api/v1/me** to read balance **without** spending a credit. To buy more credits, sign in at **Account** (`/account`) and complete **Stripe Checkout** when the operator has configured Stripe (`STRIPE_SECRET_KEY`, webhook to `POST /webhooks/stripe`).

    **Writes & provenance:** most create/update operations require a nested **source_reference** (citation) with `source_category`, a valid `http` or `https` `url`, and optional metadata. Mutating HTTP methods require an API key with **can_edit**. **PATCH /api/v1/source_references/:public_id** uses a special shape: top-level `provenance` for a new citation plus `source_reference` for field updates.

    **Pagination:** list and search responses include **Pagy**-style metadata (`page`, `per_page`, `returned`, `total_count`, …), typically up to **100** rows per page unless noted. Filter query params on people/organizations resolve to **hex `public_id`** values echoed in `meta.filters`.

    **Editors:** **GET /api/v1/zero_result_searches** requires **can_edit** on the API key; it lists saved zero-hit queries for follow-up.

    **Human-readable docs:** see `/docs/api` on the app for an overview, links to this spec, Swagger UI, and the interactive playground.
  version: v1
externalDocs:
  description: Overview, authentication summary, and links to Swagger UI and the interactive
    playground
  url: "/docs/api"
tags:
- name: People
  description: People records and nested citations on create
- name: Organizations
  description: Organizations, hierarchy, and nested citations
- name: Search
  description: GET /api/v1/search — unified Searchkick-backed search with SQL fallback
- name: Zero-result searches
  description: Zero-hit unified searches (editor task list). GET requires can_edit;
    PATCH marks complete.
- name: Memberships
  description: Person ↔ organization roles; writes attach a new source_reference row
- name: Places
  description: Geocoded locations on people, organizations, or memberships
- name: Name aliases
  description: Alternate names on people or organizations
- name: Source references
  description: Standalone citations; PATCH uses provenance + partial body
- name: Lookups
  description: Targeted queries — GET /api/v1/lookup/email returns full directory
    payloads for each EmailAddress match; GET /api/v1/lookup/domain_contacts returns
    Searchkick-backed contacts for a hostname; GET /api/v1/organizations?domain= filters
    by hostname.
- name: Account
  description: Current user and API credit balance (no credit charge for GET /me)
paths:
  "/api/v1/lookup/email":
    get:
      summary: Lookup by email
      tags:
      - Lookups
      parameters:
      - name: email
        in: query
        required: false
        description: Full email address (case-insensitive match). Omit or leave empty
          for 422.
        schema:
          type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/EmailLookupResponse"
        '422':
          description: Missing or invalid email
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorUnprocessableEntity"
  "/api/v1/lookup/domain_contacts":
    get:
      summary: Lookup contacts by organization domain
      tags:
      - Lookups
      parameters:
      - name: domain
        in: query
        required: false
        description: Organization domain or URL. Normalized before Searchkick lookup;
          omit or leave empty for 422.
        schema:
          type: string
      - name: page
        in: query
        required: false
        description: Contacts page number, 1-based.
        schema:
          type: integer
          minimum: 1
      - name: per_page
        in: query
        required: false
        description: Contacts per page, capped by the API limit.
        schema:
          type: integer
          minimum: 1
      - name: skip_research_queue
        in: query
        required: false
        description: When true, do not enqueue a zero-result domain lookup for research.
        schema:
          type: boolean
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/DomainContactsLookupResponse"
        '422':
          description: Missing or invalid domain
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorUnprocessableEntity"
        '503':
          description: Searchkick unavailable
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorServiceUnavailable"
  "/api/v1/me":
    get:
      summary: Current user and credit balance
      tags:
      - Account
      description: Does not consume an API credit. Use to check remaining credits
        and edit permission.
      responses:
        '200':
          description: OK
          headers:
            X-API-Credits-Remaining:
              description: Remaining credits after this response, or `unlimited` for
                admins
              schema:
                type: string
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MeResponse"
        '401':
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorUnauthorized"
  "/api/v1/memberships":
    get:
      summary: List memberships
      tags:
      - Memberships
      parameters:
      - name: person_public_id
        in: query
        required: false
        description: Filter memberships for this person (32-char hex)
        schema:
          type: string
      - name: organization_public_id
        in: query
        required: false
        description: Filter memberships for this organization (32-char hex)
        schema:
          type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MembershipsIndexResponse"
    post:
      summary: Create membership
      tags:
      - Memberships
      description: |-
        Links a Person to an Organization with a role (the "add person to org" flow). Resolve
        both sides by `public_id` (preferred); legacy numeric `person_id` / `organization_id`
        still work. Optional `title` gives the membership a human-readable label such as
        "Head of Outreach". `start_date` is required; `end_date` marks a past role.

        **Body shapes accepted:** canonical (`{ "membership": { … }, "source_reference": { … } }`),
        `source` / `citation` aliases for the citation, or a flat top-level body. You can also
        nest `email_addresses_attributes` (or the friendlier `emails: [ "addr", … ]`) to attach
        work emails on create.

        To create a person and their first membership in a **single** API call, use
        `POST /api/v1/people/quick_add` with `organization_public_id` set.

        Requires `can_edit`. Costs one credit.
      parameters: []
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MembershipShowResponse"
      requestBody:
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/MembershipCreateBody"
        required: true
  "/api/v1/memberships/{public_id}":
    parameters:
    - name: public_id
      in: path
      required: true
      description: Membership public id (32 hex characters)
      schema:
        type: string
        pattern: "^[0-9a-f]{32}$"
    get:
      summary: Show membership
      tags:
      - Memberships
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MembershipShowResponse"
    patch:
      summary: Update membership
      tags:
      - Memberships
      description: |-
        Updates a Membership and records a fresh citation. `source_reference` (or `source` /
        `citation`) is required; any of `role`, `title`, `start_date`, `end_date` is optional.
        Set `end_date` to mark the role as past.

        Requires `can_edit`. Costs one credit.
      parameters: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/MembershipShowResponse"
      requestBody:
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/MembershipUpdateBody"
        required: true
    delete:
      summary: Delete membership
      tags:
      - Memberships
      responses:
        '204':
          description: No content
  "/api/v1/places":
    get:
      summary: List places
      tags:
      - Places
      parameters:
      - name: person_public_id
        in: query
        required: false
        schema:
          type: string
      - name: organization_public_id
        in: query
        required: false
        schema:
          type: string
      - name: membership_public_id
        in: query
        required: false
        schema:
          type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/PlacesIndexResponse"
    post:
      summary: Create place
      tags:
      - Places
      description: |-
        Attaches a geocoded location (home, office, venue, campus, …) to a Person,
        Organization, or Membership. Pick **exactly one** owner:

        * `person_public_id` — place lives on the person,
        * `organization_public_id` — place lives on the org,
        * `membership_public_id` — place is scoped to a specific role,
        * or the polymorphic pair `placeable_type` + `placeable_id` (legacy).

        Provide at least `address` (recommended — addresses hide the precise coordinates in API
        responses) or `latitude` + `longitude`. `source` / `citation` aliases work for the
        citation. Requires `can_edit`. Costs one credit.
      parameters: []
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/PlaceShowResponse"
      requestBody:
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/PlaceCreateBody"
        required: true
  "/api/v1/places/{public_id}":
    parameters:
    - name: public_id
      in: path
      required: true
      schema:
        type: string
        pattern: "^[0-9a-f]{32}$"
    get:
      summary: Show place
      tags:
      - Places
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/PlaceShowResponse"
    patch:
      summary: Update place
      tags:
      - Places
      description: |-
        Updates a Place row and records a fresh citation. Owner switches are allowed — include
        a new owner key (e.g. `organization_public_id`) to re-parent the place. Otherwise only
        `name`, `address`, and `latitude`/`longitude` are mutated. Requires `can_edit`.
      parameters: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/PlaceShowResponse"
      requestBody:
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/PlaceUpdateBody"
        required: true
    delete:
      summary: Delete place
      tags:
      - Places
      responses:
        '204':
          description: No content
  "/api/v1/name_aliases":
    get:
      summary: List name aliases
      tags:
      - Name aliases
      parameters:
      - name: person_public_id
        in: query
        required: false
        schema:
          type: string
      - name: organization_public_id
        in: query
        required: false
        schema:
          type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NameAliasesIndexResponse"
    post:
      summary: Create name alias
      tags:
      - Name aliases
      description: |-
        Attaches an alternate name (maiden name, nickname, d/b/a, trade name) to a Person or
        Organization so it surfaces in search. Resolve the owner by `person_public_id` or
        `organization_public_id` (preferred) — or fall back to `aliasable_type` +
        `aliasable_id`. `source` / `citation` aliases work. Requires `can_edit`.

        Tip: to add aliases as part of a person's initial record, include them in the
        `POST /api/v1/people` body (`aliases: [ "…" ]`) or on `POST /api/v1/people/quick_add`.
      parameters: []
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NameAliasShowResponse"
      requestBody:
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/NameAliasCreateBody"
        required: true
  "/api/v1/name_aliases/{public_id}":
    parameters:
    - name: public_id
      in: path
      required: true
      schema:
        type: string
        pattern: "^[0-9a-f]{32}$"
    get:
      summary: Show name alias
      tags:
      - Name aliases
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NameAliasShowResponse"
    patch:
      summary: Update name alias
      tags:
      - Name aliases
      description: |-
        Renames an alias row. Only `name` is mutable — to move an alias to a different owner,
        delete and recreate it. `source_reference` (or `source` / `citation`) is required.
      parameters: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/NameAliasShowResponse"
      requestBody:
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/NameAliasUpdateBody"
        required: true
    delete:
      summary: Delete name alias
      tags:
      - Name aliases
      responses:
        '204':
          description: No content
  "/api/v1/source_references":
    get:
      summary: List source references
      tags:
      - Source references
      parameters:
      - name: person_public_id
        in: query
        required: false
        schema:
          type: string
      - name: organization_public_id
        in: query
        required: false
        schema:
          type: string
      - name: membership_public_id
        in: query
        required: false
        schema:
          type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SourceReferencesIndexResponse"
    post:
      summary: Create source reference
      tags:
      - Source references
      description: |-
        Attaches a standalone citation to an existing Person, Organization, or Membership.

        Most callers don't need this endpoint — other write endpoints (create/update people,
        organizations, memberships, places, name aliases) already accept `source_reference`
        inline. Use this when you want to add another citation to a record later without
        changing any of its fields.
      parameters: []
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SourceReferenceShowResponse"
      requestBody:
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/SourceReferenceCreateBody"
        required: true
  "/api/v1/source_references/{public_id}":
    parameters:
    - name: public_id
      in: path
      required: true
      schema:
        type: string
        pattern: "^[0-9a-f]{32}$"
    get:
      summary: Show source reference
      tags:
      - Source references
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SourceReferenceShowResponse"
    patch:
      summary: Update source reference
      tags:
      - Source references
      description: |-
        Updates an existing citation **and** attaches a *new* citation recording who/when the
        change was made. The `provenance` block (required) is the new citation for the
        underlying record; the `source_reference` block (optional) holds any field updates on
        this row.
      parameters: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SourceReferenceShowResponse"
      requestBody:
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/SourceReferenceUpdateBody"
        required: true
    delete:
      summary: Delete source reference
      tags:
      - Source references
      responses:
        '204':
          description: No content
  "/api/v1/organizations":
    get:
      summary: List organizations
      tags:
      - Organizations
      parameters:
      - name: q
        in: query
        required: false
        description: Full-text query (Searchkick with SQL fallback). Combined with
          structured filters using AND.
        schema:
          type: string
      - name: domain
        in: query
        required: false
        description: Exact hostname match after normalization (scheme/`www.` stripped).
          Ignores `q` and other filters when set.
        schema:
          type: string
      - name: person_public_id
        in: query
        required: false
        description: Filter organizations that have a membership for this person (32-char
          hex public_id).
        schema:
          type: string
      - name: person_public_ids
        in: query
        required: false
        description: Comma-separated or repeated person public ids (OR within this
          dimension).
        schema:
          type: string
      - name: membership_public_id
        in: query
        required: false
        description: Filter to the organization that owns this membership (32-char
          hex `public_id`). Preferred over legacy `membership_id`.
        schema:
          type: string
      - name: membership_public_ids
        in: query
        required: false
        description: Comma-separated or repeated membership public ids (OR).
        schema:
          type: string
      - name: membership_id
        in: query
        required: false
        description: 'Legacy: numeric membership row id (prefer `membership_public_id`).'
        schema:
          type: integer
      - name: membership_ids
        in: query
        required: false
        description: 'Legacy: comma-separated membership row ids (OR).'
        schema:
          type: string
      - name: organization_type
        in: query
        required: false
        description: Single organization_type enum value (e.g. company, school, government).
        schema:
          type: string
      - name: organization_types
        in: query
        required: false
        description: Comma-separated organization types (OR).
        schema:
          type: string
      - name: place_public_id
        in: query
        required: false
        description: Filter organizations that have this place (32-char hex `public_id`).
          Preferred over legacy `place_id`.
        schema:
          type: string
      - name: place_public_ids
        in: query
        required: false
        description: Comma-separated or repeated place public ids (OR).
        schema:
          type: string
      - name: place_id
        in: query
        required: false
        description: 'Legacy: numeric place row id (prefer `place_public_id`).'
        schema:
          type: integer
      - name: place_ids
        in: query
        required: false
        description: 'Legacy: comma-separated place row ids (OR).'
        schema:
          type: string
      responses:
        '200':
          description: OK — up to 100 organizations when `q` is omitted
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/OrganizationsIndexResponse"
    post:
      summary: Create organization
      tags:
      - Organizations
      description: |-
        Creates an Organization with a citation in one transaction.

        **Body shapes accepted (pick one):**

        1. Canonical nested — `{ "organization": { … }, "source_reference": { … } }`.
        2. Friendlier — `source` or `citation` instead of `source_reference`; `category` inside
           the citation for `source_category`.
        3. Flat top-level — send profile fields without the `organization:` wrapper, e.g.
           `{ "name": "…", "domain": "…", "organization_type": "…", "source": { … } }`.

        The `advertiser` alias also works (identical behaviour and payloads, just different JSON
        keys — see `POST /api/v1/advertisers`).

        To link a Person to the new Organization in the same request, use
        `POST /api/v1/people/quick_add` with `organization_public_id` set to the returned
        `public_id`. Requires `can_edit`. Costs one credit.
      parameters: []
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/OrganizationCreateResponse"
      requestBody:
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/OrganizationCreateBody"
        required: true
  "/api/v1/organizations/{public_id}":
    parameters:
    - name: public_id
      in: path
      required: true
      description: Organization public id (32 hex characters)
      schema:
        type: string
        pattern: "^[0-9a-f]{32}$"
    get:
      summary: Show organization
      tags:
      - Organizations
      responses:
        '200':
          description: OK — includes `source_references`
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/OrganizationShowResponse"
        '404':
          description: Unknown `public_id`
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorNotFound"
    patch:
      summary: Update organization
      tags:
      - Organizations
      description: |-
        Updates an Organization and records a fresh citation. The `source_reference` block (or
        `source` / `citation`) is required on every update. Any field in `organization` is
        optional; omitted fields are left as-is.

        Setting `parent_organization_public_id` to `null` clears the hierarchy; setting a non-empty
        `public_id` changes the stable URL id (32 hex chars, must be unique).

        Requires `can_edit`. Costs one credit.
      parameters: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/OrganizationCreateResponse"
      requestBody:
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/OrganizationUpdateBody"
        required: true
  "/api/v1/organizations/quick_add":
    post:
      summary: Quick add organization
      tags:
      - Organizations
      description: |-
        Minimal one-call endpoint for adding an organization to the directory. Takes a **flat**
        JSON body — no `organization:` wrapper, no manual `email_addresses_attributes` /
        `name_aliases_attributes` nesting.

        **Required:** `name`, `domain`, `organization_type`, and a citation. The easiest way to
        supply the citation is `source_url` (URL only — `source_category` defaults to `internal`).
        For richer citations send a nested `source` / `citation` / `source_reference` object.

        **Optional: attach a first member in the same call.** Supply a nested `person` block with
        `first_name` + `last_name` and, optionally, `email`, `role`, `title`, `start_date`. When
        present, a Person and Membership are created in the same transaction and each record gets
        its own citation copy.

        **Optional arrays:** `aliases` / `name_aliases` and `emails` accept either strings or
        `{ address, verified_at }` / `{ name }` objects.

        Returns `{ "organization": {…}, "person": {…}?, "membership": {…}? }`. Requires
        `can_edit`. Costs one credit (plus one each for the optional person and membership).
      parameters: []
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/OrganizationQuickAddResponse"
        '400':
          description: Bad Request — citation missing
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorProvenanceRequired"
      requestBody:
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/OrganizationQuickAddBody"
        required: true
  "/api/v1/people":
    get:
      summary: List people
      tags:
      - People
      parameters:
      - name: q
        in: query
        required: false
        description: Full-text query (Searchkick with SQL fallback). Combined with
          structured filters using AND.
        schema:
          type: string
      - name: organization_public_id
        in: query
        required: false
        description: Filter people linked to this organization (32-char hex `public_id`).
          OR with `organization_public_ids`.
        schema:
          type: string
      - name: organization_public_ids
        in: query
        required: false
        description: Comma-separated or repeated organization public ids (OR within
          this dimension).
        schema:
          type: string
      - name: membership_public_id
        in: query
        required: false
        description: Filter to the person who owns this membership (32-char hex `public_id`).
          Preferred over legacy numeric `membership_id`.
        schema:
          type: string
      - name: membership_public_ids
        in: query
        required: false
        description: Comma-separated or repeated membership public ids (OR).
        schema:
          type: string
      - name: membership_id
        in: query
        required: false
        description: 'Legacy: numeric membership row id (prefer `membership_public_id`).'
        schema:
          type: integer
      - name: membership_ids
        in: query
        required: false
        description: 'Legacy: comma-separated membership row ids (OR).'
        schema:
          type: string
      - name: place_public_id
        in: query
        required: false
        description: Filter people with this place (32-char hex `public_id`). Preferred
          over legacy `place_id`.
        schema:
          type: string
      - name: place_public_ids
        in: query
        required: false
        description: Comma-separated or repeated place public ids (OR).
        schema:
          type: string
      - name: place_id
        in: query
        required: false
        description: 'Legacy: numeric place row id (prefer `place_public_id`).'
        schema:
          type: integer
      - name: place_ids
        in: query
        required: false
        description: 'Legacy: comma-separated place row ids (OR).'
        schema:
          type: string
      responses:
        '200':
          description: OK — up to 100 people when `q` is omitted
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/PeopleIndexResponse"
    post:
      summary: Create person
      tags:
      - People
      description: |-
        Creates a Person record plus a citation on the same transaction.

        **Required:** a profile body (at minimum `first_name` or `last_name`) and a citation with
        `source_category` and an `http(s)` `url`.

        **Body shapes accepted (pick one):**

        1. Canonical nested — `{ "person": { … }, "source_reference": { … } }`.
        2. Friendlier nested — same as above but `source` or `citation` instead of
           `source_reference`; inside the citation, `category` is accepted for `source_category`.
        3. Flat top-level — omit the `person:` wrapper and send profile fields at the top of the
           body. Combine with `source: { … }` for the citation.

        **Nested collections:** you may use Rails-style `name_aliases_attributes` /
        `email_addresses_attributes`, or send the friendlier `aliases` / `emails` arrays. Arrays
        can contain strings (`"Ada L"`, `"ada@example.com"`) or objects (`{ "name": "Ada L" }`,
        `{ "address": "ada@example.com", "verified_at": "…" }`).

        For the common "add a person (optionally under an organization)" case, prefer
        `POST /api/v1/people/quick_add` — it takes a flat body and only requires
        `first_name`, `last_name`, and `source_url`.

        Requires an API key with `can_edit`. Costs one credit.
      parameters: []
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/PersonCreateResponse"
      requestBody:
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/PersonCreateBody"
        required: true
  "/api/v1/people/quick_add":
    post:
      summary: Quick add person
      tags:
      - People
      description: |-
        Minimal one-call endpoint for adding a person to the directory. Takes a **flat** JSON body
        — no `person:` wrapper, no manual `email_addresses_attributes`/`name_aliases_attributes`
        nesting.

        **Required:** `first_name`, `last_name`, and a citation. The easiest way to supply the
        citation is `source_url` (URL only — `source_category` defaults to `internal`). For
        richer citations, send a nested `source` / `citation` / `source_reference` object.

        **Optional: attach to an organization in the same call.** When `organization_public_id`
        is provided, a `Membership` row is created in the same transaction (with `role`
        defaulting to `member`) and a citation is attached to both records. The organization
        must already exist — create it first with `POST /api/v1/organizations` if needed.

        **Optional arrays:** `emails` (strings or `{ address, verified_at }` objects) and
        `aliases` / `name_aliases` (strings or `{ name }` objects). For a single email just use
        `"email": "ada@example.com"`.

        Returns `{ "person": {…}, "membership": {…}? }`. Requires `can_edit`. Costs one credit.
      parameters: []
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/PersonQuickAddResponse"
        '400':
          description: Bad Request — citation missing
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorProvenanceRequired"
      requestBody:
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/PersonQuickAddBody"
        required: true
  "/api/v1/people/{public_id}":
    parameters:
    - name: public_id
      in: path
      required: true
      description: Person public id (32 hex characters)
      schema:
        type: string
        pattern: "^[0-9a-f]{32}$"
    get:
      summary: Show person
      tags:
      - People
      responses:
        '200':
          description: OK — includes `source_references`
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/PersonShowResponse"
        '404':
          description: Unknown `public_id`
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorNotFound"
    patch:
      summary: Update person
      tags:
      - People
      description: |-
        Updates a Person and records a fresh citation on the same transaction. The citation is
        always required on writes — send it as `source_reference` (or the aliases `source` /
        `citation`). Fields inside `person` are optional; omit a field to leave it unchanged.

        Setting a new `public_id` changes the stable URL id — it must be 32 hex characters and
        must be unique across all People.

        Requires `can_edit`. Costs one credit.
      parameters: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/PersonCreateResponse"
      requestBody:
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/PersonUpdateBody"
        required: true
  "/api/v1/search":
    get:
      summary: Unified search
      tags:
      - Search
      parameters:
      - name: q
        in: query
        required: false
        description: Search query across people, organizations, memberships, places,
          and source references.
        schema:
          type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SearchResponse"
  "/api/v1/zero_result_searches":
    get:
      summary: List zero-hit searches (edit API keys only)
      tags:
      - Zero-result searches
      parameters:
      - name: include_completed
        in: query
        required: false
        description: 'When true, include rows with completed_at set (alias: include_resolved).'
        schema:
          type: boolean
      - name: kind
        in: query
        required: false
        description: 'Filter by kind: unified_search, domain_lookup, or email_lookup.'
        schema:
          type: string
          enum:
          - unified_search
          - domain_lookup
          - email_lookup
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ZeroResultSearchesIndexResponse"
        '403':
          description: Forbidden (read-only API key)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorForbidden"
  "/api/v1/zero_result_searches/{id}":
    parameters:
    - name: id
      in: path
      required: true
      description: Zero-result search row id
      schema:
        type: integer
    patch:
      summary: Update (mark complete or reopen)
      tags:
      - Zero-result searches
      parameters: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ZeroResultSearchShowResponse"
      requestBody:
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/ZeroResultSearchUpdateBody"
        required: true
servers:
- url: http://localhost:3000
  description: Local development
- url: "/"
  description: Same origin (relative)
security:
- bearerAuth: []
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: 'Per-user API key. Send as `Authorization: Bearer <key>` or duplicate
        the same value in `X-Api-Key`. Copy the key from `/account/api-key` when signed
        in; required for all `/api/v1` requests.'
  schemas:
    SourceReference:
      type: object
      properties:
        id:
          type: integer
        public_id:
          type: string
        source_category:
          type: string
        url:
          type: string
          format: uri
        title:
          type: string
          nullable: true
        notes:
          type: string
          nullable: true
        captured_at:
          type: string
          format: date-time
          nullable: true
        referencable_type:
          type: string
          nullable: true
        referencable_id:
          type: integer
          nullable: true
        created_from_ip:
          type: string
          nullable: true
          description: Client IP when the citation was created (from X-Forwarded-For
            when behind a proxy)
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      required:
      - id
      - source_category
      - url
      - created_at
      - updated_at
    Person:
      type: object
      properties:
        public_id:
          type: string
          description: Opaque public identifier (hex)
        first_name:
          type: string
          nullable: true
        last_name:
          type: string
          nullable: true
        middle_name:
          type: string
          nullable: true
        birthday:
          type: string
          format: date
          nullable: true
        description:
          type: string
          nullable: true
        linkedin_url:
          type: string
          format: uri
          nullable: true
          description: Full LinkedIn profile URL (https://www.linkedin.com/in/… or
            /company/…)
        facebook_url:
          type: string
          format: uri
          nullable: true
          description: Full Facebook profile or page URL (https://www.facebook.com/…)
        x_url:
          type: string
          format: uri
          nullable: true
          description: Full X (Twitter) profile URL (https://x.com/… or https://twitter.com/…)
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      required:
      - public_id
      - created_at
      - updated_at
    Organization:
      type: object
      properties:
        public_id:
          type: string
        name:
          type: string
        domain:
          type: string
          description: Public internet hostname (e.g. example.com or app.example.com).
            Not an IP address; not .local/.test/.onion/etc. https:// and www. are
            stripped on save.
        email_local_part_pattern:
          type: string
          nullable: true
          description: Optional. Local-part template id for work email (e.g. first_dot_last
            → jane.doe@domain). Set manually or via OrganizationEmailLocalPartPattern
            inference from existing @domain emails.
        description:
          type: string
          nullable: true
        organization_type:
          type: string
        linkedin_url:
          type: string
          format: uri
          nullable: true
          description: Full LinkedIn company or showcase page URL
        facebook_url:
          type: string
          format: uri
          nullable: true
          description: Full Facebook page URL
        x_url:
          type: string
          format: uri
          nullable: true
          description: Full X (Twitter) profile or page URL
        crunchbase_url:
          type: string
          format: uri
          nullable: true
          description: Crunchbase organization profile URL (https://www.crunchbase.com/organization/…)
        estimated_employee_count:
          type: integer
          nullable: true
          description: Approximate headcount (non-negative integer when set)
        parent_organization_public_id:
          type: string
          nullable: true
          description: 32-hex public_id of the parent organization when this org is
            nested under another
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      required:
      - public_id
      - name
      - domain
      - organization_type
      - created_at
      - updated_at
    PersonWithReferences:
      allOf:
      - "$ref": "#/components/schemas/Person"
      - type: object
        properties:
          source_references:
            type: array
            items:
              "$ref": "#/components/schemas/SourceReference"
        required:
        - source_references
    OrganizationWithReferences:
      allOf:
      - "$ref": "#/components/schemas/Organization"
      - type: object
        properties:
          source_references:
            type: array
            items:
              "$ref": "#/components/schemas/SourceReference"
        required:
        - source_references
    PeopleIndexResponse:
      type: object
      properties:
        people:
          type: array
          items:
            "$ref": "#/components/schemas/Person"
        meta:
          "$ref": "#/components/schemas/PeopleIndexMeta"
      required:
      - people
      - meta
    PersonShowResponse:
      type: object
      properties:
        person:
          "$ref": "#/components/schemas/PersonWithReferences"
      required:
      - person
    PersonCreateResponse:
      type: object
      properties:
        person:
          "$ref": "#/components/schemas/PersonWithReferences"
      required:
      - person
    OrganizationsIndexResponse:
      type: object
      properties:
        organizations:
          type: array
          items:
            "$ref": "#/components/schemas/Organization"
        meta:
          "$ref": "#/components/schemas/OrganizationsIndexMeta"
      required:
      - organizations
      - meta
    OrganizationShowResponse:
      type: object
      properties:
        organization:
          "$ref": "#/components/schemas/OrganizationWithReferences"
      required:
      - organization
    OrganizationCreateResponse:
      type: object
      properties:
        organization:
          "$ref": "#/components/schemas/OrganizationWithReferences"
      required:
      - organization
    MeResponse:
      type: object
      description: Current API user summary (GET /api/v1/me does not deduct a credit).
      properties:
        email:
          type: string
          format: email
        can_edit:
          type: boolean
          description: Whether this API key may create or update directory data
        api_credits_balance:
          type: integer
          nullable: true
          description: Remaining credits; null when `api_credits_unlimited` is true
            (admin)
        api_credits_unlimited:
          type: boolean
          description: When true, API usage is not metered (site admins)
        starter_credits:
          type: integer
          description: Free credits granted to new accounts (same as database default
            for new users)
          example: 1000
        stripe_billing_available:
          type: boolean
          description: Whether the deployment has Stripe configured for credit purchases
            via the web Account page
      required:
      - email
      - can_edit
      - api_credits_unlimited
      - starter_credits
      - stripe_billing_available
    ErrorUnauthorized:
      type: object
      description: Standard error envelope for 401 responses.
      properties:
        error:
          type: string
          example: unauthorized
        message:
          type: string
      required:
      - error
      - message
    ErrorBadRequest:
      type: object
      description: Standard error envelope for 400 responses.
      properties:
        error:
          type: string
          example: bad_request
        message:
          type: string
        details:
          type: object
          description: Optional structured hints (e.g. missing_parameter).
          additionalProperties: true
      required:
      - error
      - message
    ErrorNotFound:
      type: object
      description: Standard error envelope for 404 responses.
      properties:
        error:
          type: string
          example: not_found
        message:
          type: string
      required:
      - error
      - message
    ErrorUnprocessableEntity:
      type: object
      description: Standard error envelope for 422 validation responses.
      properties:
        error:
          type: string
          example: unprocessable_entity
        message:
          type: string
        details:
          type: object
          properties:
            messages:
              type: array
              items:
                type: string
            field_errors:
              type: object
              additionalProperties: true
          required:
          - messages
          - field_errors
      required:
      - error
      - message
      - details
    ErrorServiceUnavailable:
      type: object
      description: Standard error envelope for 503 responses when a required upstream
        service is unavailable.
      properties:
        error:
          type: string
          example: search_unavailable
        message:
          type: string
      required:
      - error
      - message
    ErrorProvenanceRequired:
      type: object
      description: Provenance/citation missing or incomplete (400).
      properties:
        error:
          type: string
          example: provenance_required
        message:
          type: string
      required:
      - error
      - message
    ErrorForbidden:
      type: object
      description: Standard error envelope for 403 responses (e.g. edit-only endpoints).
      properties:
        error:
          type: string
          example: forbidden
        message:
          type: string
      required:
      - error
      - message
    ZeroResultSearchActor:
      type: object
      nullable: true
      description: User who ran the search (web session or API key) or who marked
        complete.
      properties:
        id:
          type: integer
        email:
          type: string
      required:
      - id
      - email
    ZeroResultSearchItem:
      type: object
      description: 'A saved miss to research later: unified search, unknown domain,
        or unknown email.'
      properties:
        id:
          type: integer
        kind:
          type: string
          enum:
          - unified_search
          - domain_lookup
          - email_lookup
          description: unified_search — GET /api/v1/search; domain_lookup — GET /api/v1/organizations?domain=;
            email_lookup — GET /api/v1/lookup/email.
        query:
          type: string
        source:
          type: string
          enum:
          - web
          - api
        miss_count:
          type: integer
          description: How many times this normalized query returned zero hits.
        first_seen_at:
          type: string
          format: date-time
        last_seen_at:
          type: string
          format: date-time
        searched_by:
          "$ref": "#/components/schemas/ZeroResultSearchActor"
        completed_at:
          type: string
          format: date-time
          nullable: true
          description: When the task was marked complete (PATCH); omitted from pending
            lists.
        completed_by:
          "$ref": "#/components/schemas/ZeroResultSearchActor"
      required:
      - id
      - kind
      - query
      - source
      - miss_count
      - first_seen_at
      - last_seen_at
    ZeroResultSearchesIndexResponse:
      type: object
      properties:
        zero_result_searches:
          type: array
          items:
            "$ref": "#/components/schemas/ZeroResultSearchItem"
        meta:
          "$ref": "#/components/schemas/PaginationMeta"
      required:
      - zero_result_searches
      - meta
    ZeroResultSearchShowResponse:
      type: object
      properties:
        zero_result_search:
          "$ref": "#/components/schemas/ZeroResultSearchItem"
      required:
      - zero_result_search
    ZeroResultSearchUpdateBody:
      type: object
      properties:
        completed:
          type: boolean
          description: When true, sets completed_at (and completed_by to the API user);
            when false, clears completion.
        resolved:
          type: boolean
          description: Alias for `completed` (backward compatibility).
        completed_at:
          type: string
          format: date-time
          description: Optional completion timestamp when `completed` is true (defaults
            to now).
    PersonCreateBody:
      type: object
      description: 'Canonical nested shape: `person` holds the profile fields, `source_reference`
        holds the citation. The **friendlier aliases** `source` and `citation` also
        work in place of `source_reference`, and flat top-level fields (no `person:`
        wrapper) are also accepted. See `POST /api/v1/people/quick_add` for a one-call
        shorthand that only needs `first_name`, `last_name`, and `source_url`.'
      properties:
        person:
          type: object
          properties:
            public_id:
              type: string
              pattern: "^[0-9a-f]{32}$"
              description: Optional; auto-generated when omitted
            first_name:
              type: string
            last_name:
              type: string
            middle_name:
              type: string
              nullable: true
            birthday:
              type: string
              format: date
              nullable: true
            description:
              type: string
              nullable: true
            linkedin_url:
              type: string
              format: uri
              nullable: true
            facebook_url:
              type: string
              format: uri
              nullable: true
            x_url:
              type: string
              format: uri
              nullable: true
            name_aliases_attributes:
              type: array
              description: 'Nested name aliases (`accepts_nested_attributes_for`).
                Use `_destroy: true` with `id` to remove.'
              items:
                type: object
                properties:
                  id:
                    type: integer
                    description: Existing alias row id (omit on create)
                  name:
                    type: string
                  _destroy:
                    type: boolean
                    description: When true with `id`, deletes the alias
        source_reference:
          type: object
          properties:
            source_category:
              type: string
            url:
              type: string
              format: uri
              description: http or https URL
            title:
              type: string
              nullable: true
            notes:
              type: string
              nullable: true
            captured_at:
              type: string
              format: date-time
              nullable: true
          required:
          - source_category
          - url
      required:
      - person
      - source_reference
      example:
        person:
          first_name: Ada
          last_name: Lovelace
          linkedin_url: https://www.linkedin.com/in/ada-lovelace
        source_reference:
          source_category: social
          url: https://www.linkedin.com/in/ada-lovelace
          title: LinkedIn profile
    PersonUpdateBody:
      type: object
      properties:
        person:
          type: object
          properties:
            public_id:
              type: string
              pattern: "^[0-9a-f]{32}$"
              description: Changes stable URL id when unique
            first_name:
              type: string
            last_name:
              type: string
            middle_name:
              type: string
              nullable: true
            birthday:
              type: string
              format: date
              nullable: true
            description:
              type: string
              nullable: true
            linkedin_url:
              type: string
              format: uri
              nullable: true
            facebook_url:
              type: string
              format: uri
              nullable: true
            x_url:
              type: string
              format: uri
              nullable: true
            name_aliases_attributes:
              type: array
              description: 'Nested name aliases (`accepts_nested_attributes_for`).
                Use `_destroy: true` with `id` to remove.'
              items:
                type: object
                properties:
                  id:
                    type: integer
                    description: Existing alias row id (omit on create)
                  name:
                    type: string
                  _destroy:
                    type: boolean
                    description: When true with `id`, deletes the alias
        source_reference:
          type: object
          properties:
            source_category:
              type: string
            url:
              type: string
              format: uri
              description: http or https URL
            title:
              type: string
              nullable: true
            notes:
              type: string
              nullable: true
            captured_at:
              type: string
              format: date-time
              nullable: true
          required:
          - source_category
          - url
      required:
      - source_reference
    PersonQuickAddBody:
      type: object
      description: 'Minimal one-call body for adding a person to the directory. Everything
        is top-level (no `person:` wrapper). At least one citation signal is required:
        either `source_url` (shorthand) or a nested `source` / `citation` / `source_reference`
        object with `source_category` and `url`. When `organization_public_id` is
        supplied, a Membership row is created in the same transaction and a citation
        is recorded on both records.'
      properties:
        first_name:
          type: string
        last_name:
          type: string
        middle_name:
          type: string
          nullable: true
        description:
          type: string
          nullable: true
        linkedin_url:
          type: string
          format: uri
          nullable: true
        facebook_url:
          type: string
          format: uri
          nullable: true
        x_url:
          type: string
          format: uri
          nullable: true
        email:
          type: string
          format: email
          nullable: true
          description: 'Single address shortcut. `emails: ["…", …]` also accepted.'
        emails:
          type: array
          nullable: true
          description: Array of address strings or `{ address, verified_at }` objects.
          items:
            oneOf:
            - type: string
            - type: object
              properties:
                address:
                  type: string
                verified_at:
                  type: string
                  format: date-time
                  nullable: true
              required:
              - address
        aliases:
          type: array
          nullable: true
          description: 'Alternate names — strings or `{ name: ''…'' }` objects. `name_aliases`
            is also accepted.'
          items:
            oneOf:
            - type: string
            - type: object
              properties:
                name:
                  type: string
              required:
              - name
        source_url:
          type: string
          format: uri
          nullable: true
          description: Shorthand citation URL. Defaults `source_category` to `internal`.
            Set `source_category` at the top level to override.
        source_category:
          type: string
          nullable: true
          description: Used with `source_url` to override the default `internal` category
            (e.g. `news`, `profile`, `press_release`).
        source:
          type: object
          nullable: true
          description: Nested citation — full control. `category` is accepted as an
            alias for `source_category`.
          properties:
            source_category:
              type: string
            category:
              type: string
              description: Alias for `source_category`.
            url:
              type: string
              format: uri
            title:
              type: string
              nullable: true
            notes:
              type: string
              nullable: true
            captured_at:
              type: string
              format: date-time
              nullable: true
        organization_public_id:
          type: string
          nullable: true
          description: When set, a Membership is also created linking this person
            to the organization. The org must already exist (see POST /api/v1/organizations).
        role:
          type: string
          nullable: true
          description: Membership role. Defaults to `member` when `organization_public_id`
            is present.
          enum:
          - member
          - employee
          - manager
          - founder
          - owner
          - student
          - teacher
          - volunteer
          - board_member
          - advisor
        title:
          type: string
          nullable: true
          description: Membership title (e.g. "Head of Outreach").
        start_date:
          type: string
          format: date
          nullable: true
        end_date:
          type: string
          format: date
          nullable: true
      required:
      - first_name
      - last_name
      example:
        first_name: Ada
        last_name: Lovelace
        email: ada@example.com
        aliases:
        - Ada Lovelace (née Byron)
        linkedin_url: https://www.linkedin.com/in/ada-lovelace
        source_url: https://example.com/press/ada-joins
        source_category: news
        organization_public_id: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
        role: founder
        title: Chief Analytical Officer
        start_date: '1843-10-01'
    PersonQuickAddResponse:
      type: object
      description: Always includes the `person`. Includes `membership` only when `organization_public_id`
        was supplied.
      properties:
        person:
          "$ref": "#/components/schemas/PersonWithReferences"
        membership:
          type: object
          nullable: true
          properties:
            public_id:
              type: string
            role:
              type: string
              enum:
              - member
              - employee
              - manager
              - founder
              - owner
              - student
              - teacher
              - volunteer
              - board_member
              - advisor
            title:
              type: string
              nullable: true
            start_date:
              type: string
              format: date
              nullable: true
            end_date:
              type: string
              format: date
              nullable: true
            person_public_id:
              type: string
            organization_public_id:
              type: string
            organization_name:
              type: string
              nullable: true
          required:
          - public_id
          - role
          - person_public_id
          - organization_public_id
      required:
      - person
    OrganizationCreateBody:
      type: object
      description: Creates an Organization. `organization` wraps the profile fields;
        `source_reference` (or the friendlier `source` / `citation` aliases) is the
        required citation. Flat top-level fields (no `organization:` wrapper) are
        also accepted. When linking a person immediately afterwards, use POST /api/v1/memberships
        with the returned `public_id` — or POST /api/v1/people/quick_add to do both
        in one call.
      properties:
        organization:
          type: object
          properties:
            public_id:
              type: string
              pattern: "^[0-9a-f]{32}$"
              description: Optional; auto-generated when omitted
            name:
              type: string
            domain:
              type: string
              description: Public internet hostname; https:// and www. are stripped
            email_local_part_pattern:
              type: string
              nullable: true
              description: Optional template id (e.g. first_dot_last); omit to auto-detect
                via refresh or inference
            description:
              type: string
              nullable: true
            organization_type:
              type: string
            parent_organization_public_id:
              type: string
              nullable: true
              description: 32-hex public_id of parent org; omit or null for top-level
            linkedin_url:
              type: string
              format: uri
              nullable: true
            facebook_url:
              type: string
              format: uri
              nullable: true
            x_url:
              type: string
              format: uri
              nullable: true
            crunchbase_url:
              type: string
              format: uri
              nullable: true
            estimated_employee_count:
              type: integer
              nullable: true
            name_aliases_attributes:
              type: array
              description: 'Nested name aliases (`accepts_nested_attributes_for`).
                Use `_destroy: true` with `id` to remove.'
              items:
                type: object
                properties:
                  id:
                    type: integer
                    description: Existing alias row id (omit on create)
                  name:
                    type: string
                  _destroy:
                    type: boolean
                    description: When true with `id`, deletes the alias
          required:
          - name
          - domain
          - organization_type
        source_reference:
          type: object
          properties:
            source_category:
              type: string
            url:
              type: string
              format: uri
              description: http or https URL
            title:
              type: string
              nullable: true
            notes:
              type: string
              nullable: true
            captured_at:
              type: string
              format: date-time
              nullable: true
          required:
          - source_category
          - url
      required:
      - organization
      - source_reference
      example:
        organization:
          name: Analytical Engine Ltd.
          domain: analyticalengine.example
          organization_type: company
          description: Mechanical computing pioneers.
        source_reference:
          source_category: website
          url: https://analyticalengine.example/about
          title: About us
    OrganizationUpdateBody:
      type: object
      properties:
        organization:
          type: object
          properties:
            public_id:
              type: string
              pattern: "^[0-9a-f]{32}$"
              description: Changes stable URL id when unique
            name:
              type: string
            domain:
              type: string
              description: Public internet hostname; https:// and www. are stripped
            email_local_part_pattern:
              type: string
              nullable: true
            description:
              type: string
              nullable: true
            organization_type:
              type: string
            parent_organization_public_id:
              type: string
              nullable: true
              description: 32-hex public_id of parent; null or empty clears parent
            linkedin_url:
              type: string
              format: uri
              nullable: true
            facebook_url:
              type: string
              format: uri
              nullable: true
            x_url:
              type: string
              format: uri
              nullable: true
            crunchbase_url:
              type: string
              format: uri
              nullable: true
            estimated_employee_count:
              type: integer
              nullable: true
            name_aliases_attributes:
              type: array
              description: 'Nested name aliases (`accepts_nested_attributes_for`).
                Use `_destroy: true` with `id` to remove.'
              items:
                type: object
                properties:
                  id:
                    type: integer
                    description: Existing alias row id (omit on create)
                  name:
                    type: string
                  _destroy:
                    type: boolean
                    description: When true with `id`, deletes the alias
        source_reference:
          type: object
          properties:
            source_category:
              type: string
            url:
              type: string
              format: uri
              description: http or https URL
            title:
              type: string
              nullable: true
            notes:
              type: string
              nullable: true
            captured_at:
              type: string
              format: date-time
              nullable: true
          required:
          - source_category
          - url
      required:
      - source_reference
    OrganizationQuickAddBody:
      type: object
      description: 'Minimal one-call body for adding an organization. All profile
        fields are **top-level** (no `organization:` wrapper). At least one citation
        signal is required: either `source_url` (shorthand) or a nested `source` /
        `citation` / `source_reference` object with `source_category` and `url`. When
        a `person` block is supplied, a Person + Membership are created in the same
        transaction (role defaults to `member`) — each record gets its own citation
        copy.'
      properties:
        name:
          type: string
        domain:
          type: string
          description: Public internet hostname; https:// and www. are stripped.
        organization_type:
          type: string
          description: See GET /api/v1/organizations for valid values (e.g. company,
            school, government).
        description:
          type: string
          nullable: true
        linkedin_url:
          type: string
          format: uri
          nullable: true
        facebook_url:
          type: string
          format: uri
          nullable: true
        x_url:
          type: string
          format: uri
          nullable: true
        crunchbase_url:
          type: string
          format: uri
          nullable: true
        estimated_employee_count:
          type: integer
          nullable: true
        email_local_part_pattern:
          type: string
          nullable: true
          description: Optional template id (e.g. first_dot_last); omit to auto-detect.
        parent_organization_public_id:
          type: string
          nullable: true
          description: 32-hex public_id of a parent org; omit or null for top-level.
        aliases:
          type: array
          nullable: true
          description: 'Alternate names — strings or `{ name: ''…'' }` objects. `name_aliases`
            is also accepted.'
          items:
            oneOf:
            - type: string
            - type: object
              properties:
                name:
                  type: string
              required:
              - name
        emails:
          type: array
          nullable: true
          description: Org-level contact addresses — strings or `{ address, verified_at
            }` objects.
          items:
            oneOf:
            - type: string
            - type: object
              properties:
                address:
                  type: string
                verified_at:
                  type: string
                  format: date-time
                  nullable: true
              required:
              - address
        source_url:
          type: string
          format: uri
          nullable: true
          description: Shorthand citation URL. Defaults `source_category` to `internal`.
            Set `source_category` at the top level to override.
        source_category:
          type: string
          nullable: true
          description: Used with `source_url` to override the default `internal` category
            (e.g. `organization`, `website`, `news`).
        source:
          type: object
          nullable: true
          description: Nested citation — full control. `category` is accepted as an
            alias for `source_category`.
          properties:
            source_category:
              type: string
            category:
              type: string
              description: Alias for `source_category`.
            url:
              type: string
              format: uri
            title:
              type: string
              nullable: true
            notes:
              type: string
              nullable: true
            captured_at:
              type: string
              format: date-time
              nullable: true
        person:
          type: object
          nullable: true
          description: 'Optional first member. When supplied, a Person is created
            and linked via a Membership in the same transaction. `role` defaults to
            `member`; `email` is a single-address shortcut (also accepts `emails:
            […]`). Omit this block to just create the organization.'
          properties:
            first_name:
              type: string
            last_name:
              type: string
            middle_name:
              type: string
              nullable: true
            linkedin_url:
              type: string
              format: uri
              nullable: true
            facebook_url:
              type: string
              format: uri
              nullable: true
            x_url:
              type: string
              format: uri
              nullable: true
            email:
              type: string
              format: email
              nullable: true
              description: Single address shortcut.
            emails:
              type: array
              nullable: true
              items:
                oneOf:
                - type: string
                - type: object
                  properties:
                    address:
                      type: string
                    verified_at:
                      type: string
                      format: date-time
                      nullable: true
                  required:
                  - address
            aliases:
              type: array
              nullable: true
              items:
                oneOf:
                - type: string
                - type: object
                  properties:
                    name:
                      type: string
                  required:
                  - name
            role:
              type: string
              nullable: true
              enum:
              - member
              - employee
              - manager
              - founder
              - owner
              - student
              - teacher
              - volunteer
              - board_member
              - advisor
              description: Membership role; defaults to `member`.
            title:
              type: string
              nullable: true
              description: Membership title (e.g. "Chief Analytical Officer").
            start_date:
              type: string
              format: date
              nullable: true
            end_date:
              type: string
              format: date
              nullable: true
      required:
      - name
      - domain
      - organization_type
      example:
        name: Analytical Engine Ltd.
        domain: analyticalengine.example
        organization_type: company
        description: Mechanical computing pioneers.
        aliases:
        - AEL
        source_url: https://analyticalengine.example/about
        source_category: organization
        person:
          first_name: Ada
          last_name: Lovelace
          email: ada@analyticalengine.example
          role: founder
          title: Chief Analytical Officer
          start_date: '1843-10-01'
    OrganizationQuickAddResponse:
      type: object
      description: Always includes the created `organization`. When a `person` block
        was supplied, the response also includes a `person` summary and a `membership`
        row linking the two.
      properties:
        organization:
          "$ref": "#/components/schemas/OrganizationWithReferences"
        person:
          type: object
          nullable: true
          properties:
            public_id:
              type: string
            first_name:
              type: string
            last_name:
              type: string
            full_name:
              type: string
              nullable: true
          required:
          - public_id
        membership:
          type: object
          nullable: true
          properties:
            public_id:
              type: string
            role:
              type: string
              enum:
              - member
              - employee
              - manager
              - founder
              - owner
              - student
              - teacher
              - volunteer
              - board_member
              - advisor
            title:
              type: string
              nullable: true
            start_date:
              type: string
              format: date
              nullable: true
            end_date:
              type: string
              format: date
              nullable: true
            person_public_id:
              type: string
            organization_public_id:
              type: string
            organization_name:
              type: string
              nullable: true
          required:
          - public_id
          - role
          - person_public_id
          - organization_public_id
      required:
      - organization
    SearchHit:
      type: object
      description: Normalized hit from SearchHitPresenter (unified search UI and GET
        /api/v1/search).
      properties:
        kind:
          type: string
          description: Record type discriminator (e.g. person, organization, membership,
            place, source_reference)
        label:
          type: string
          description: Short human-readable type label
        title:
          type: string
        subtitle:
          type: string
          nullable: true
        ref:
          type: string
          nullable: true
          description: Stable public_id (hex) for directory models; may be null for
            unexpected record types
      required:
      - kind
      - label
      - title
    Membership:
      type: object
      properties:
        public_id:
          type: string
        id:
          type: integer
        role:
          type: string
          enum:
          - member
          - employee
          - manager
          - founder
          - owner
          - student
          - teacher
          - volunteer
          - board_member
          - advisor
        start_date:
          type: string
          format: date
          nullable: true
          description: ISO 8601 date string
        end_date:
          type: string
          format: date
          nullable: true
        person_id:
          type: integer
        organization_id:
          type: integer
        person_public_id:
          type: string
          nullable: true
        organization_public_id:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      required:
      - public_id
      - id
      - role
      - person_id
      - organization_id
      - created_at
      - updated_at
    MembershipsIndexResponse:
      type: object
      properties:
        memberships:
          type: array
          items:
            "$ref": "#/components/schemas/Membership"
        meta:
          "$ref": "#/components/schemas/PaginationMeta"
      required:
      - memberships
      - meta
    MembershipShowResponse:
      type: object
      properties:
        membership:
          "$ref": "#/components/schemas/Membership"
      required:
      - membership
    MembershipCreateBody:
      type: object
      description: Links a Person to an Organization with a role. Resolve both sides
        by `public_id` (preferred); legacy numeric `person_id` / `organization_id`
        are still accepted. Flat top-level fields (no `membership:` wrapper) and `source`
        / `citation` aliases for `source_reference` also work.
      properties:
        membership:
          type: object
          properties:
            person_public_id:
              type: string
              description: 'Preferred: resolve person by public id'
            organization_public_id:
              type: string
            person_id:
              type: integer
              description: Legacy numeric FK
            organization_id:
              type: integer
            role:
              type: string
              enum:
              - member
              - employee
              - manager
              - founder
              - owner
              - student
              - teacher
              - volunteer
              - board_member
              - advisor
            title:
              type: string
              nullable: true
              description: Human-readable title (e.g. "Head of Outreach")
            start_date:
              type: string
              format: date
            end_date:
              type: string
              format: date
              nullable: true
          required:
          - role
          - start_date
        source_reference:
          type: object
          properties:
            source_category:
              type: string
            url:
              type: string
              format: uri
            title:
              type: string
              nullable: true
            notes:
              type: string
              nullable: true
            captured_at:
              type: string
              format: date-time
              nullable: true
          required:
          - source_category
          - url
      required:
      - membership
      - source_reference
      example:
        membership:
          person_public_id: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
          organization_public_id: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
          role: employee
          title: Senior Engineer
          start_date: '2022-03-01'
        source_reference:
          source_category: social
          url: https://www.linkedin.com/in/ada-lovelace/
          title: LinkedIn role
    MembershipUpdateBody:
      type: object
      properties:
        membership:
          type: object
          properties:
            role:
              type: string
              enum:
              - member
              - employee
              - manager
              - founder
              - owner
              - student
              - teacher
              - volunteer
              - board_member
              - advisor
            start_date:
              type: string
              format: date
            end_date:
              type: string
              format: date
              nullable: true
        source_reference:
          type: object
          properties:
            source_category:
              type: string
            url:
              type: string
              format: uri
            title:
              type: string
              nullable: true
            notes:
              type: string
              nullable: true
            captured_at:
              type: string
              format: date-time
              nullable: true
          required:
          - source_category
          - url
      required:
      - source_reference
    Place:
      type: object
      properties:
        public_id:
          type: string
        id:
          type: integer
        name:
          type: string
          nullable: true
        address:
          type: string
          nullable: true
        latitude:
          type: number
          nullable: true
        longitude:
          type: number
          nullable: true
        placeable_type:
          type: string
          description: STI owner class name (Person, Organization, or Membership)
        placeable_id:
          type: integer
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      required:
      - public_id
      - id
      - placeable_type
      - placeable_id
      - created_at
      - updated_at
    PlacesIndexResponse:
      type: object
      properties:
        places:
          type: array
          items:
            "$ref": "#/components/schemas/Place"
        meta:
          "$ref": "#/components/schemas/PaginationMeta"
      required:
      - places
      - meta
    PlaceShowResponse:
      type: object
      properties:
        place:
          "$ref": "#/components/schemas/Place"
      required:
      - place
    PlaceCreateBody:
      type: object
      description: Attaches a geocoded location to a Person, Organization, or Membership.
        Resolve the owner by `person_public_id` / `organization_public_id` / `membership_public_id`
        (preferred), or with the polymorphic pair `placeable_type` + `placeable_id`.
        Either `address` or `latitude` + `longitude` is required. `source` / `citation`
        are accepted as aliases for `source_reference`.
      properties:
        place:
          type: object
          properties:
            person_public_id:
              type: string
            organization_public_id:
              type: string
            membership_public_id:
              type: string
            membership_id:
              type: integer
              description: Legacy numeric membership id
            placeable_type:
              type: string
              enum:
              - Person
              - Organization
              - Membership
            placeable_id:
              type: integer
            name:
              type: string
            address:
              type: string
              nullable: true
            latitude:
              type: number
              nullable: true
            longitude:
              type: number
              nullable: true
          description: 'Set exactly one owner: *_public_id(s), legacy membership_id,
            or placeable_type + placeable_id'
        source_reference:
          type: object
          properties:
            source_category:
              type: string
            url:
              type: string
              format: uri
            title:
              type: string
              nullable: true
            notes:
              type: string
              nullable: true
            captured_at:
              type: string
              format: date-time
              nullable: true
          required:
          - source_category
          - url
      required:
      - place
      - source_reference
      example:
        place:
          person_public_id: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
          name: Home office
          address: 123 Analytical Lane, London, UK
        source_reference:
          source_category: social
          url: https://example.com/ada-london
    PlaceUpdateBody:
      type: object
      properties:
        place:
          type: object
          properties:
            person_public_id:
              type: string
            organization_public_id:
              type: string
            membership_public_id:
              type: string
            membership_id:
              type: integer
            placeable_type:
              type: string
            placeable_id:
              type: integer
            name:
              type: string
            address:
              type: string
              nullable: true
            latitude:
              type: number
              nullable: true
            longitude:
              type: number
              nullable: true
        source_reference:
          type: object
          properties:
            source_category:
              type: string
            url:
              type: string
              format: uri
            title:
              type: string
              nullable: true
            notes:
              type: string
              nullable: true
            captured_at:
              type: string
              format: date-time
              nullable: true
          required:
          - source_category
          - url
      required:
      - source_reference
    NameAlias:
      type: object
      properties:
        public_id:
          type: string
        id:
          type: integer
        name:
          type: string
        aliasable_type:
          type: string
        aliasable_id:
          type: integer
      required:
      - public_id
      - id
      - name
      - aliasable_type
      - aliasable_id
    NameAliasesIndexResponse:
      type: object
      properties:
        name_aliases:
          type: array
          items:
            "$ref": "#/components/schemas/NameAlias"
        meta:
          "$ref": "#/components/schemas/PaginationMeta"
      required:
      - name_aliases
      - meta
    NameAliasShowResponse:
      type: object
      properties:
        name_alias:
          "$ref": "#/components/schemas/NameAlias"
      required:
      - name_alias
    NameAliasCreateBody:
      type: object
      description: Attaches an alternate name (e.g. maiden name, nickname, trade name)
        to a Person or Organization. Resolve the owner by `person_public_id` / `organization_public_id`
        (preferred) or fall back to `aliasable_type` + `aliasable_id`. `source` /
        `citation` are accepted as aliases for `source_reference`.
      properties:
        name_alias:
          type: object
          properties:
            person_public_id:
              type: string
            organization_public_id:
              type: string
            aliasable_type:
              type: string
              enum:
              - Person
              - Organization
            aliasable_id:
              type: integer
            name:
              type: string
          required:
          - name
        source_reference:
          type: object
          properties:
            source_category:
              type: string
            url:
              type: string
              format: uri
            title:
              type: string
              nullable: true
            notes:
              type: string
              nullable: true
            captured_at:
              type: string
              format: date-time
              nullable: true
          required:
          - source_category
          - url
      required:
      - name_alias
      - source_reference
      example:
        name_alias:
          person_public_id: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
          name: Ada King, Countess of Lovelace
        source_reference:
          source_category: public_data
          url: https://en.wikipedia.org/wiki/Ada_Lovelace
    NameAliasUpdateBody:
      type: object
      properties:
        name_alias:
          type: object
          properties:
            name:
              type: string
        source_reference:
          type: object
          properties:
            source_category:
              type: string
            url:
              type: string
              format: uri
            title:
              type: string
              nullable: true
            notes:
              type: string
              nullable: true
            captured_at:
              type: string
              format: date-time
              nullable: true
          required:
          - source_category
          - url
      required:
      - source_reference
    SourceReferencesIndexResponse:
      type: object
      properties:
        source_references:
          type: array
          items:
            "$ref": "#/components/schemas/SourceReference"
        meta:
          "$ref": "#/components/schemas/PaginationMeta"
      required:
      - source_references
      - meta
    SourceReferenceShowResponse:
      type: object
      properties:
        source_reference:
          "$ref": "#/components/schemas/SourceReference"
      required:
      - source_reference
    SourceReferenceCreateBody:
      type: object
      properties:
        source_reference:
          type: object
          properties:
            person_public_id:
              type: string
            organization_public_id:
              type: string
            membership_public_id:
              type: string
            membership_id:
              type: integer
            referencable_type:
              type: string
              enum:
              - Person
              - Organization
              - Membership
            referencable_id:
              type: integer
            source_category:
              type: string
            url:
              type: string
              format: uri
            title:
              type: string
              nullable: true
            notes:
              type: string
              nullable: true
            captured_at:
              type: string
              format: date-time
              nullable: true
          required:
          - source_category
          - url
      required:
      - source_reference
    SourceReferenceUpdateBody:
      type: object
      description: PATCH records a new citation from `provenance`, then applies permitted
        fields on the row.
      properties:
        provenance:
          type: object
          properties:
            source_category:
              type: string
            url:
              type: string
              format: uri
            title:
              type: string
              nullable: true
            notes:
              type: string
              nullable: true
            captured_at:
              type: string
              format: date-time
              nullable: true
          required:
          - source_category
          - url
        source_reference:
          type: object
          properties:
            source_category:
              type: string
            url:
              type: string
              format: uri
            title:
              type: string
              nullable: true
            notes:
              type: string
              nullable: true
            captured_at:
              type: string
              format: date-time
              nullable: true
      required:
      - provenance
    PaginationMeta:
      type: object
      description: Pagy-backed pagination. Use `page` and optional `per_page` query
        params on list endpoints.
      properties:
        page:
          type: integer
          description: Current page (1-based)
          example: 1
        per_page:
          type: integer
          example: 100
        returned:
          type: integer
          description: Rows in this response (≤ per_page)
        total_count:
          type: integer
          description: Total rows matching the query (same scope as Pagy)
        total_pages:
          type: integer
          description: Total number of pages
        next_page:
          type: integer
          nullable: true
          description: Next page number if another page exists
        prev_page:
          type: integer
          nullable: true
          description: Previous page number if not on first page
      required:
      - page
      - per_page
      - returned
      - total_count
      - total_pages
    PeopleIndexMeta:
      allOf:
      - "$ref": "#/components/schemas/PaginationMeta"
      - type: object
        properties:
          total_count:
            type: integer
            description: Matching rows when `q` and/or structured filters are applied.
              With SQL fallback on huge tables, this may be a lower bound; see `total_count_is_approximate`.
          total_count_is_approximate:
            type: boolean
            description: When true, `total_count` is a lower bound (bounded COUNT),
              not an exact total.
          query:
            type: string
            nullable: true
            description: Echo of the `q` parameter when present.
          filters:
            type: object
            description: 'Echo of resolved filters for Searchkick `where` (keys: organization_public_ids,
              membership_public_ids, place_public_ids — hex public ids, not numeric
              row ids).'
            additionalProperties: true
    OrganizationsIndexMeta:
      allOf:
      - "$ref": "#/components/schemas/PaginationMeta"
      - type: object
        properties:
          total_count:
            type: integer
            description: Matching rows when `q` and/or structured filters are applied.
              With SQL fallback on huge tables, this may be a lower bound; see `total_count_is_approximate`.
          total_count_is_approximate:
            type: boolean
            description: When true, `total_count` is a lower bound (bounded COUNT),
              not an exact total.
          query:
            type: string
            nullable: true
            description: Echo of the `q` parameter when present.
          filters:
            type: object
            description: Echo of resolved filters for Searchkick `where` (person_public_ids,
              membership_public_ids, place_public_ids, organization_types).
            additionalProperties: true
    SearchResponse:
      type: object
      properties:
        query:
          type: string
          description: Echo of `q` (empty string when `q` omitted)
        total_count:
          type: integer
        error:
          type: string
          nullable: true
          description: Set when Searchkick search fails with empty hits
        hits:
          type: array
          items:
            "$ref": "#/components/schemas/SearchHit"
        meta:
          allOf:
          - "$ref": "#/components/schemas/PaginationMeta"
          - type: object
            properties:
              total_count_is_approximate:
                type: boolean
                description: When true (SQL fallback), `total_count` may be a lower
                  bound across model types.
      required:
      - query
      - total_count
      - hits
      - meta
    EmailLookupResponse:
      type: object
      required:
      - email
      - match_count
      - matches
      properties:
        email:
          type: string
          description: Normalized lowercase address used for matching
        match_count:
          type: integer
        matches:
          type: array
          items:
            type: object
            description: Includes email_address, emailable_type, emailable_id, and
              person / organization / membership payloads (GET show parity).
            additionalProperties: true
    DomainContactOrganization:
      type: object
      properties:
        public_id:
          type: string
        name:
          type: string
        domain:
          type: string
        email_addresses:
          type: array
          description: Verified organization-level email addresses.
          items:
            type: object
            description: Verified email address exposed by public API responses.
            properties:
              public_id:
                type: string
              address:
                type: string
                format: email
              verified_at:
                type: string
                format: date-time
            required:
            - public_id
            - address
            - verified_at
      required:
      - public_id
      - name
      - domain
      - email_addresses
    DomainContact:
      type: object
      description: Membership/contact row returned by GET /api/v1/lookup/domain_contacts.
      properties:
        public_id:
          type: string
        role:
          type: string
          enum:
          - member
          - employee
          - manager
          - founder
          - owner
          - student
          - teacher
          - volunteer
          - board_member
          - advisor
        title:
          type: string
          nullable: true
        start_date:
          type: string
          format: date
          nullable: true
        end_date:
          type: string
          format: date
          nullable: true
        person_public_id:
          type: string
          nullable: true
        person_full_name:
          type: string
          nullable: true
        organization_public_id:
          type: string
        organization_name:
          type: string
          nullable: true
        organization_domain:
          type: string
          nullable: true
        location_summary:
          type: string
          nullable: true
        places:
          type: array
          items:
            "$ref": "#/components/schemas/Place"
        email_addresses:
          type: array
          description: Verified emails attached to the membership row.
          items:
            type: object
            description: Verified email address exposed by public API responses.
            properties:
              public_id:
                type: string
              address:
                type: string
                format: email
              verified_at:
                type: string
                format: date-time
            required:
            - public_id
            - address
            - verified_at
        person_email_addresses:
          type: array
          description: Verified emails attached to the person.
          items:
            type: object
            description: Verified email address exposed by public API responses.
            properties:
              public_id:
                type: string
              address:
                type: string
                format: email
              verified_at:
                type: string
                format: date-time
            required:
            - public_id
            - address
            - verified_at
      required:
      - public_id
      - role
      - person_public_id
      - person_full_name
      - organization_public_id
      - location_summary
      - places
      - email_addresses
      - person_email_addresses
    DomainContactsLookupResponse:
      type: object
      required:
      - domain
      - organization_count
      - contact_count
      - organizations
      - contacts
      - meta
      - search_backend
      properties:
        domain:
          type: string
          description: Normalized domain used for Searchkick lookup.
        organization_count:
          type: integer
          description: Number of organization records returned in this response.
        contact_count:
          type: integer
          description: Total matching contact memberships for the matched organizations.
        organizations:
          type: array
          items:
            "$ref": "#/components/schemas/DomainContactOrganization"
        contacts:
          type: array
          items:
            "$ref": "#/components/schemas/DomainContact"
        meta:
          allOf:
          - "$ref": "#/components/schemas/PaginationMeta"
          - type: object
            properties:
              total_organizations:
                type: integer
                description: Total organizations matched by the normalized domain.
        search_backend:
          type: string
          enum:
          - searchkick
          description: Always `searchkick` on successful responses; Elasticsearch
            must be reachable.
        queued_for_research:
          type: boolean
          nullable: true
          description: Present on zero-result domain lookups unless skipped.
