openapi: 3.1.0
info:
  title: Arvore Link Shortener API
  version: 1.0.0
tags:
  - name: public-api
    description: Supported for third-party use, documented at /docs
servers:
  - url: https://api.arvore.link
    description: Production
  - url: https://api.{stage}.arvore.link
    description: Development
    variables:
      stage:
        default: rafael
components:
  securitySchemes:
    bearer:
      type: http
      scheme: bearer
      description: |
        An API token, created at /links/conta and prefixed `arv_`. It does not
        expire; revoke it when you are done with it. This is the scheme to use
        from anything that is not a browser.

        A token can do what its owner can to links, árvores and analytics, but
        not administer the account: changing the email, deleting the account,
        revoking sessions and creating further tokens all answer 403
        SESSION_REQUIRED and need a real sign-in. That way revoking a token
        actually ends its access.

        On the free plan an account can have **one live token at a time** and
        make **100 requests per hour**, counted per token. Revoking frees the
        slot, so rotating is revoke-then-create.
    session:
      type: apiKey
      in: cookie
      name: arvore_session
      description: |
        How the arvore.link app itself authenticates — an httpOnly cookie set by
        POST /auth/verify. Documented for completeness; a browser sends it
        automatically and nothing else can.
  responses:
    SessionRequired:
      description: >
        SESSION_REQUIRED — this endpoint administers the account, so an API
        token is refused and a signed-in session is required. Keeping token
        creation and account changes behind a real sign-in is what makes
        revoking a token actually end its access.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    RateLimited:
      description: >
        RATE_LIMITED — too many requests in the current window. The Retry-After
        header carries the seconds until it resets.
      headers:
        Retry-After:
          schema:
            type: integer
          description: Seconds until the window resets
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
  parameters:
    From:
      name: from
      in: query
      required: false
      description: Start of time range (Unix timestamp in milliseconds, inclusive)
      schema:
        type: string
        example: "1771849500000"
    To:
      name: to
      in: query
      required: false
      description: End of time range (Unix timestamp in milliseconds, inclusive)
      schema:
        type: string
        example: "1771849600000"
  schemas:
    Error:
      type: object
      description: >
        `error` is always a string — a stable code, never an object. Validation
        failures add `fields` with the per-input detail, and set `error` to the
        specific code when the schema has one (`INVALID_TAG`, `INVALID_LINK_ID`)
        or `VALIDATION_FAILED` when it does not.
      properties:
        error:
          type: string
          example: VALIDATION_FAILED
        fields:
          type: object
          description: Present on 400s. Maps each input to what was wrong with it.
          additionalProperties:
            type: array
            items:
              type: string
          example:
            url:
              - Invalid URL
    Link:
      type: object
      properties:
        tags:
          type: array
          maxItems: 10
          items:
            type: string
            pattern: ^[0-9a-z-]{1,24}$
          description: >
            For grouping links so they can be found later. Lowercase, digits and
            hyphens, at most 10 per link. Always present on read, `[]` for an
            untagged link.
          example:
            - campanha-verao
            - instagram
        expires_at:
          type: integer
          format: int64
          nullable: true
          description: >
            When the link stops working, in milliseconds since the epoch. Always
            present on read, `null` when the link does not expire. After this
            moment the link redirects to `expiration_url` if one is set, and
            answers 404 otherwise.
          example: 1785400000000
        expiration_url:
          type: string
          format: uri
          nullable: true
          description: >
            Where an expired link goes instead of answering 404. Always present
            on read, `null` when unset. Only meaningful alongside `expires_at`.
          example: https://example.com/campaign-over
        targeting:
          type: object
          nullable: true
          description: >
            Per-visitor destinations. Always present on read, `null` when the
            link has none. Evaluated on every request: the operating system rule
            wins over the country rule, and a visitor matching neither goes to
            `url`. An expired link ignores targeting entirely.
          properties:
            geo:
              type: object
              additionalProperties:
                type: string
                format: uri
              description: >
                Country code (ISO 3166-1 alpha-2, uppercase, as CloudFront
                reports it) to destination. At most 50 entries.
              example:
                BR: https://example.com.br
                PT: https://example.pt
            ios:
              type: string
              format: uri
              description: >
                Where iOS and iPadOS visitors go. Both, deliberately — an iPad
                user sent to the Play Store is the bug this feature exists to
                prevent.
              example: https://apps.apple.com/app/id1234567890
            android:
              type: string
              format: uri
              example: https://play.google.com/store/apps/details?id=com.example
        has_password:
          type: boolean
          description: >
            Whether a password is required to open the link. A boolean and never
            the hash: the hash is stored only on the record the redirect app
            reads, so there is nothing here to leak. The password itself is
            write-only and can never be read back — the same once-only property
            an API token has.
          example: false
        og_title:
          type: string
          nullable: true
          maxLength: 120
          description: >
            Custom title for the share card. When any og_ field is set, the
            redirect answers HTML carrying these tags to social scrapers instead
            of a 301; everyone else, including search crawlers, still gets the
            301.
          example: Promotion ends Sunday
        og_description:
          type: string
          nullable: true
          maxLength: 240
          example: Free shipping on everything
        og_image:
          type: string
          format: uri
          nullable: true
          description: >
            Image for the share card, 1200x630. Any absolute http(s) URL — it
            does not have to be hosted here. When unset but a title is present,
            an image is generated from the title, so a card needs no upload to
            exist.
          example: https://assets.arvore.link/avatars/ws000001/og-a1b2c3d4-x9.webp
        id:
          type: string
          description: Internal link id, 8 characters
          example: a1b2c3d4
        slug:
          type: string
          description: Path on the redirect domain
          example: a1b2
        url:
          type: string
          format: uri
          example: https://example.com
        short_url:
          type: string
          format: uri
          example: https://arvr.ink/a1b2
        redirect_domain:
          type: string
          example: arvr.ink
        created_at:
          type: integer
          description: Unix timestamp in milliseconds
          example: 1739111400000
        updated_at:
          type: integer
          description: Unix timestamp in milliseconds
          example: 1739111400000
        deleted_at:
          type: integer
          nullable: true
          example: null
    Linktree:
      type: object
      properties:
        id:
          type: string
          example: k9fx2n1a
        slug:
          type: string
          description: Path on the redirect domain, 1-50 chars of [0-9a-z-]
          example: meu-perfil
        title:
          type: string
          example: minha página
        bio:
          type: string
          nullable: true
          example: links que importam
        avatar_url:
          type: string
          format: uri
          nullable: true
          example: https://example.com/foto.jpg
        published:
          type: boolean
          description: Unpublished árvores 404 on the public page
          example: false
        created_at:
          type: integer
          example: 1739111400000
        updated_at:
          type: integer
          example: 1739111400000
    LinktreeItem:
      type: object
      properties:
        id:
          type: string
          example: p3qr7s2t
        title:
          type: string
          example: instagram
        url:
          type: string
          format: uri
          description: Final destination
          example: https://instagram.com/perfil
        short_url:
          type: string
          format: uri
          description: Short link the public page points at, so clicks are counted
          example: https://arvr.ink/x7y8
        position:
          type: integer
          description: Ascending order on the public page
          example: 0
        active:
          type: boolean
          description: Inactive items are hidden from the public page
          example: true
        created_at:
          type: integer
          example: 1739111400000
        updated_at:
          type: integer
          example: 1739111400000
    LinktreeAnalytics:
      type: object
      properties:
        id:
          type: string
          example: k9fx2n1a
        slug:
          type: string
          example: meu-perfil
        title:
          type: string
          example: minha página
        published:
          type: boolean
          example: true
        views:
          type: integer
          description: Visits to the public page in the range
          example: 128
        clicks:
          type: integer
          description: Sum of item clicks in the range
          example: 41
        items:
          type: array
          items:
            type: object
            properties:
              id:
                type: string
                example: p3qr7s2t
              title:
                type: string
                example: instagram
              short_url:
                type: string
                format: uri
                nullable: true
                example: https://arvr.ink/x7y8
              active:
                type: boolean
                example: true
              count:
                type: integer
                example: 22
    Account:
      type: object
      properties:
        user:
          type: object
          properties:
            id:
              type: string
              example: a1b2c3d4
            email:
              type: string
              format: email
              example: voce@exemplo.com
            name:
              type: string
              nullable: true
              example: Rafael
            avatar_url:
              type: string
              format: uri
              nullable: true
              example: https://assets.arvore.link/avatars/w1/user-a1b2c3d4-k9fx2n1a.webp
            created_at:
              type: integer
              example: 1739111400000
        workspace:
          type: object
          properties:
            id:
              type: string
              example: w1x2y3z4
            name:
              type: string
              nullable: true
              example: Pessoal
    ApiToken:
      type: object
      properties:
        id:
          type: string
          description: Identifies the token for revocation — not the token itself
          example: tk8f2c1a
        name:
          type: string
          example: deploy da CI
        prefix:
          type: string
          description: The first characters of the real token, to recognise it by
          example: arv_9f2c…
        created_at:
          type: integer
          example: 1785000000000
        last_used_at:
          type: integer
          nullable: true
          description: >
            Updated at most once every five minutes, so it is accurate to within
            that, not to the request. Null until first used.
          example: 1785003600000
        revoked_at:
          type: integer
          nullable: true
          description: >
            When it was revoked, or null while it still works. A revoked token
            keeps its row so the list can show that it existed and when it
            stopped being trusted.
          example: 1785007200000
    Session:
      type: object
      properties:
        id:
          type: string
          description: Last six characters of the token — never the token itself
          example: 7s2t9x
        created_at:
          type: integer
          example: 1739111400000
        expires_at:
          type: integer
          example: 1741703400000
        current:
          type: boolean
          description: True for the session making the request
          example: true
        device:
          type: string
          nullable: true
          enum:
            - desktop
            - mobile
            - tablet
            - smarttv
            - null
          description: From the CloudFront device hints, falling back to the User-Agent
          example: mobile
        browser:
          type: string
          nullable: true
          example: Chrome 145
        platform:
          type: string
          nullable: true
          example: iOS
        country:
          type: string
          nullable: true
          description: From the CloudFront viewer headers, when present
          example: BR
        region_name:
          type: string
          nullable: true
          example: São Paulo
        city:
          type: string
          nullable: true
          example: São Paulo
    ClickEvent:
      type: object
      properties:
        ts:
          type: string
          description: Sort key in the format "{timestamp_ms}#{random}"
          example: 1771849585571#k9fx2n1a
        timestamp:
          type: integer
          description: Unix timestamp in milliseconds
          example: 1771849585571
        country:
          type: string
          nullable: true
          example: BR
        region:
          type: string
          nullable: true
          example: SP
        region_name:
          type: string
          nullable: true
          example: São Paulo
        timezone:
          type: string
          nullable: true
          example: America/Sao_Paulo
        lat:
          type: number
          nullable: true
          example: -23.673
        lng:
          type: number
          nullable: true
          example: -46.5302
        device:
          type: string
          enum:
            - mobile
            - tablet
            - smarttv
            - desktop
          example: desktop
        platform:
          type: string
          nullable: true
          example: macOS
        browser:
          type: string
          nullable: true
          example: Chrome 145
        referer:
          type: string
          nullable: true
          example: https://twitter.com
paths:
  /account:
    get:
      summary: Get the signed-in user and workspace
      tags:
        - public-api
      x-pt: descobrir de quem é o token e qual workspace ele acessa
      security:
        - bearer: []
        - session: []
      responses:
        "200":
          description: Account
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Account"
        "401":
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /reports:
    post:
      summary: Report a short link for abuse
      tags:
        - public-api
      x-pt: denunciar um link curto
      description: >
        Flags a short link for a human to review. **A report never changes what
        the link

        does** — anything automatic would hand everyone a button for taking down
        each

        other's links, and abusing the reporting system would be easier than the
        abuse

        it prevents. An admin decides.


        Public and unauthenticated, because somebody who received a phishing
        link has no

        account here and never will. Capped twice: per caller address, and per
        reported

        slug — the second stops one link being reported enough times to bury
        every other

        report, which is the denial of service a reporting form actually
        invites.


        `url` must be a link on the redirect domain; anything else answers 400

        NOT_A_SHORT_LINK rather than being stored, or the queue becomes a
        dumping ground

        an admin has to wade through. A report about a slug that does not exist
        answers

        200 and stores nothing — a 404 would make this a slug oracle.


        **Part of the public API**, unlike the other unauthenticated writes
        here. The

        first-party beacons are excluded because a stranger writing into our
        analytics

        is not a feature; for this endpoint a stranger writing *is* the feature.
        An

        abuse desk, a brand-protection service or a threat feed reporting in
        bulk is

        exactly who it exists for, and making them reverse-engineer it would be

        perverse.


        No authentication and none accepted — a token buys nothing here. Reports
        are

        judged on their content by a person, so an identified reporter gets no
        more

        weight than an anonymous one, and the rate limits apply either way.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - url
                - reason
              properties:
                url:
                  type: string
                  maxLength: 2000
                  description: The short link, as pasted. A bare slug is accepted too.
                  example: https://arvr.ink/a1b2
                reason:
                  type: string
                  enum:
                    - phishing
                    - malware
                    - spam
                    - fraude
                    - outro
                  example: phishing
                detail:
                  type: string
                  maxLength: 1000
                  example: Recebi por SMS dizendo que era o banco
                email:
                  type: string
                  format: email
                  maxLength: 200
                  description: >
                    Optional and not verified. Requiring an address would filter
                    out the person this exists for; it is only so an admin can
                    follow up.
                  example: quem@reportou.com
      responses:
        "200":
          description: Report received, or silently discarded for an unknown slug
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    example: true
        "400":
          description: NOT_A_SHORT_LINK or a validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
  /links:
    get:
      summary: List short links
      tags:
        - public-api
      x-pt: listar seus links curtos
      description: |
        Links in the caller's workspace, newest first. Deleted links and
        linktree-owned links are excluded — an árvore and each of its items own
        a link record, and those are reported through /linktrees.
      security:
        - bearer: []
        - session: []
      responses:
        "200":
          description: Links
          content:
            application/json:
              schema:
                type: object
                properties:
                  links:
                    type: array
                    items:
                      $ref: "#/components/schemas/Link"
        "401":
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    post:
      summary: Create short link
      tags:
        - public-api
      x-pt: criar um link curto
      description: |
        Creates a short link for the given URL. Authentication is optional —
        authenticated links are scoped to the user's workspace, unauthenticated
        links are stored under "public". A slug is generated when omitted.
      security:
        - bearer: []
        - session: []
        - {}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - url
              properties:
                url:
                  type: string
                  format: uri
                  example: https://example.com
                slug:
                  type: string
                  pattern: ^[0-9a-z-]{1,50}$
                  example: meu-link
                tags:
                  type: array
                  maxItems: 10
                  items:
                    type: string
                    pattern: ^[0-9a-z-]{1,24}$
                  description: >
                    Lowercase, digits and hyphens, at most 10 per link.
                    Deduplicated and sorted on write. Send `[]` to clear them;
                    omit the field to leave them untouched.
                  example:
                    - campanha-verao
                    - instagram
                expires_at:
                  type: integer
                  format: int64
                  description: >
                    When the link stops working, in milliseconds since the
                    epoch. Must be in the future — a past timestamp answers 400
                    EXPIRES_AT_IN_THE_PAST, since it is almost always a timezone
                    mistake and accepting it would create a link that is already
                    dead.
                  example: 1785400000000
                expiration_url:
                  type: string
                  format: uri
                  description: >
                    Where an expired link goes instead of answering 404.
                    Requires `expires_at` — on its own it answers 400
                    EXPIRATION_URL_WITHOUT_EXPIRY rather than being stored and
                    never used.
                  example: https://example.com/campaign-over
                targeting:
                  type: object
                  nullable: true
                  description: >
                    Per-visitor destinations. Replaced wholesale rather than
                    merged — send the full set of rules, or `null` to remove
                    them all. Merging would make removing one country
                    impossible, since there would be no way to express its
                    absence.
                  properties:
                    geo:
                      type: object
                      additionalProperties:
                        type: string
                        format: uri
                      description: >
                        Country code (ISO 3166-1 alpha-2) to destination.
                        Upper-cased on write, since CloudFront reports uppercase
                        and a lowercase key would be a rule that can never
                        match. At most 50 entries (TOO_MANY_RULES).
                      example:
                        BR: https://example.com.br
                    ios:
                      type: string
                      format: uri
                      example: https://apps.apple.com/app/id1234567890
                    android:
                      type: string
                      format: uri
                      example: https://play.google.com/store/apps/details?id=com.example
                password:
                  type: string
                  minLength: 4
                  maxLength: 200
                  writeOnly: true
                  description: >
                    A password visitors must type before being redirected.
                    Hashed with salted scrypt on write; never returned by any
                    endpoint, so it cannot be read back once set. Send `null` to
                    remove it (PATCH only); omit to leave it alone. Note this
                    gates the destination rather than hiding it: anyone who
                    learns the password can pass it on.
                  example: verao2026
                og_title:
                  type: string
                  nullable: true
                  maxLength: 120
                  description: >
                    Custom title for the share card. Setting any og_ field makes
                    the link serve HTML to social scrapers rather than a 301.
                  example: Promotion ends Sunday
                og_description:
                  type: string
                  nullable: true
                  maxLength: 240
                  example: Free shipping on everything
                og_image:
                  type: string
                  format: uri
                  nullable: true
                  description: >
                    Any absolute http(s) URL, 1200x630 recommended. Point at an
                    image you already host — there is no binary upload in the
                    public API, for the same reason avatar uploads are not part
                    of it. Omit it and an image is generated from the title.
                  example: https://example.com/card.png
      responses:
        "201":
          description: Link created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Link"
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: LINK_ID_ALREADY_IN_USE
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
  /links/{id}:
    patch:
      summary: Update a short link
      tags:
        - public-api
      x-pt: editar destino, id, tags, expiração, segmentação, senha ou prévia
      description: >
        Changes the destination URL, the slug, the tags or the expiry. Every
        field

        follows the same three cases: omit it to leave the stored value alone,
        send

        `null` (or `[]` for tags) to clear it, send a value to set it.
        Linktree-owned

        links are rejected: writing through here would rebuild the `slug:`
        record as

        a plain short link and make the árvore unreachable.
      security:
        - bearer: []
        - session: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            example: a1b2c3d4
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                url:
                  type: string
                  format: uri
                slug:
                  type: string
                  pattern: ^[0-9a-z-]{1,50}$
                tags:
                  type: array
                  maxItems: 10
                  items:
                    type: string
                    pattern: ^[0-9a-z-]{1,24}$
                  description: >
                    Lowercase, digits and hyphens, at most 10 per link.
                    Deduplicated and sorted on write. Send `[]` to clear them;
                    omit the field to leave them untouched.
                  example:
                    - campanha-verao
                    - instagram
                expires_at:
                  type: integer
                  format: int64
                  nullable: true
                  description: >
                    When the link stops working, in milliseconds since the
                    epoch. Must be in the future. Send `null` to remove an
                    expiry; omit the field to leave it untouched.
                  example: 1785400000000
                expiration_url:
                  type: string
                  format: uri
                  nullable: true
                  description: >
                    Where an expired link goes instead of answering 404.
                    Requires an expiry, whether sent in the same request or
                    already stored — otherwise 400
                    EXPIRATION_URL_WITHOUT_EXPIRY. Send `null` to remove it.
                  example: https://example.com/campaign-over
                targeting:
                  type: object
                  nullable: true
                  description: >
                    Per-visitor destinations. Replaced wholesale rather than
                    merged — send the full set of rules, or `null` to remove
                    them all. Merging would make removing one country
                    impossible, since there would be no way to express its
                    absence.
                  properties:
                    geo:
                      type: object
                      additionalProperties:
                        type: string
                        format: uri
                      description: >
                        Country code (ISO 3166-1 alpha-2) to destination.
                        Upper-cased on write, since CloudFront reports uppercase
                        and a lowercase key would be a rule that can never
                        match. At most 50 entries (TOO_MANY_RULES).
                      example:
                        BR: https://example.com.br
                    ios:
                      type: string
                      format: uri
                      example: https://apps.apple.com/app/id1234567890
                    android:
                      type: string
                      format: uri
                      example: https://play.google.com/store/apps/details?id=com.example
                password:
                  type: string
                  minLength: 4
                  maxLength: 200
                  writeOnly: true
                  description: >
                    A password visitors must type before being redirected.
                    Hashed with salted scrypt on write; never returned by any
                    endpoint, so it cannot be read back once set. Send `null` to
                    remove it (PATCH only); omit to leave it alone. Note this
                    gates the destination rather than hiding it: anyone who
                    learns the password can pass it on.
                  example: verao2026
                og_title:
                  type: string
                  nullable: true
                  maxLength: 120
                  description: >
                    Custom title for the share card. Setting any og_ field makes
                    the link serve HTML to social scrapers rather than a 301.
                  example: Promotion ends Sunday
                og_description:
                  type: string
                  nullable: true
                  maxLength: 240
                  example: Free shipping on everything
                og_image:
                  type: string
                  format: uri
                  nullable: true
                  description: >
                    Any absolute http(s) URL, 1200x630 recommended. Point at an
                    image you already host — there is no binary upload in the
                    public API, for the same reason avatar uploads are not part
                    of it. Omit it and an image is generated from the title.
                  example: https://example.com/card.png
      responses:
        "200":
          description: Link updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Link"
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Link not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: LINK_ID_ALREADY_IN_USE or LINKTREE_LINK_NOT_EDITABLE
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    delete:
      summary: Delete a short link
      tags:
        - public-api
      x-pt: apagar um link curto
      description: >
        Frees the slug and stops the link redirecting on the next request. The
        link

        record itself is kept with a `deleted_at` timestamp and disappears from

        `GET /links`.


        That split is deliberate rather than a compromise. Freeing the name is
        the

        outcome anyone deleting a link wants, and the 4-character namespace is
        shared

        and finite. Keeping the row is what an abuse takedown needs — the link
        stops

        working while the record survives as evidence of what it pointed at.
        Click

        events are kept for the same reason; they are keyed by link id, so a
        future

        link reusing the slug never collides with them.


        Answers 204 whether or not the link was already deleted, so a retry
        after a

        timeout is safe. Deleting the account removes everything for real.


        Árvore-owned links are refused: removing one would take the `slug:`
        record

        with it and make the árvore page or one of its buttons unreachable. Use

        `DELETE /linktrees/{id}` or the item endpoint instead.
      security:
        - bearer: []
        - session: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            example: a1b2c3d4
      responses:
        "204":
          description: Deleted, or already was
        "404":
          description: NOT_FOUND
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: LINKTREE_LINK_NOT_EDITABLE
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /links/{id}/analytics:
    get:
      summary: Get click analytics for a link
      tags:
        - public-api
      x-pt: ver os cliques de um link
      description: Click events for one short link, newest first, capped at 500.
      security:
        - bearer: []
        - session: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            example: a1b2c3d4
        - $ref: "#/components/parameters/From"
        - $ref: "#/components/parameters/To"
      responses:
        "200":
          description: Analytics data
          content:
            application/json:
              schema:
                type: object
                properties:
                  count:
                    type: integer
                    example: 42
                  clicks:
                    type: array
                    items:
                      $ref: "#/components/schemas/ClickEvent"
        "401":
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Link not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /analytics:
    get:
      summary: Get click analytics across links
      tags:
        - public-api
      x-pt: ver os cliques de todos os seus links
      description: |
        Click events for every short link in the workspace, or one link when
        link_id is given, capped at 500 per link. Linktree-owned links are
        excluded, matching GET /links.
      security:
        - bearer: []
        - session: []
      parameters:
        - name: link_id
          in: query
          required: false
          schema:
            type: string
            example: a1b2c3d4
        - $ref: "#/components/parameters/From"
        - $ref: "#/components/parameters/To"
      responses:
        "200":
          description: Analytics data
          content:
            application/json:
              schema:
                type: object
                properties:
                  count:
                    type: integer
                    example: 128
                  clicks:
                    type: array
                    items:
                      allOf:
                        - $ref: "#/components/schemas/ClickEvent"
                        - type: object
                          properties:
                            link_id:
                              type: string
                              example: a1b2c3d4
        "401":
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Link not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /linktrees:
    get:
      summary: List árvores
      tags:
        - public-api
      x-pt: listar suas árvores
      security:
        - bearer: []
        - session: []
      responses:
        "200":
          description: Linktrees
          content:
            application/json:
              schema:
                type: object
                properties:
                  linktrees:
                    type: array
                    items:
                      $ref: "#/components/schemas/Linktree"
        "401":
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    post:
      summary: Create an árvore
      tags:
        - public-api
      x-pt: criar uma árvore
      description: |
        Reserves the slug and creates the profile, unpublished. A link record is
        created alongside it so page views can be counted.
      security:
        - bearer: []
        - session: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - slug
                - title
              properties:
                slug:
                  type: string
                  pattern: ^[0-9a-z-]{1,50}$
                  example: meu-perfil
                title:
                  type: string
                  maxLength: 100
                bio:
                  type: string
                  maxLength: 300
                avatar_url:
                  type: string
                  format: uri
      responses:
        "201":
          description: Linktree created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Linktree"
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: SLUG_ALREADY_IN_USE
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /linktrees/{id}:
    get:
      summary: Get an árvore with its items
      tags:
        - public-api
      x-pt: ver uma árvore com seus itens
      description: Includes inactive items, ordered by position.
      security:
        - bearer: []
        - session: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            example: k9fx2n1a
      responses:
        "200":
          description: Linktree with items
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Linktree"
                  - type: object
                    properties:
                      items:
                        type: array
                        items:
                          $ref: "#/components/schemas/LinktreeItem"
        "401":
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    patch:
      summary: Update an árvore
      tags:
        - public-api
      x-pt: editar o perfil, o slug ou publicar uma árvore
      description: >
        Renaming the slug moves the reservation and rewrites the short_url on
        the

        árvore's link record.
      security:
        - bearer: []
        - session: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            example: k9fx2n1a
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                slug:
                  type: string
                  pattern: ^[0-9a-z-]{1,50}$
                title:
                  type: string
                  maxLength: 100
                bio:
                  type: string
                  nullable: true
                  maxLength: 300
                avatar_url:
                  type: string
                  format: uri
                  nullable: true
                published:
                  type: boolean
      responses:
        "200":
          description: Linktree updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Linktree"
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: SLUG_ALREADY_IN_USE
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    delete:
      summary: Delete an árvore
      tags:
        - public-api
      x-pt: apagar uma árvore
      description: >
        Removes the profile, its items, the slug reservation and the link
        records

        created for the page and its items. Click events are left in place.
      security:
        - bearer: []
        - session: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            example: k9fx2n1a
      responses:
        "204":
          description: Deleted
        "401":
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /linktrees/{id}/analytics:
    get:
      summary: Get analytics for one árvore
      tags:
        - public-api
      x-pt: ver as visitas e os cliques de uma árvore
      security:
        - bearer: []
        - session: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            example: k9fx2n1a
        - $ref: "#/components/parameters/From"
        - $ref: "#/components/parameters/To"
      responses:
        "200":
          description: Analytics data
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LinktreeAnalytics"
        "401":
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /linktrees/{id}/items:
    post:
      summary: Add an item to an árvore
      tags:
        - public-api
      x-pt: adicionar um item na árvore
      description: |
        Creates a short link for the destination URL and points the item at it,
        so clicks are counted. Position defaults to the end of the list.
      security:
        - bearer: []
        - session: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            example: k9fx2n1a
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - title
                - url
              properties:
                title:
                  type: string
                  maxLength: 100
                  example: instagram
                url:
                  type: string
                  format: uri
                  example: https://instagram.com/perfil
                position:
                  type: integer
                  minimum: 0
      responses:
        "201":
          description: Item created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LinktreeItem"
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Linktree not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /linktrees/{id}/items/{itemId}:
    patch:
      summary: Update an item
      tags:
        - public-api
      x-pt: editar um item da árvore
      description: Changing the URL repoints the item's short link at the new destination.
      security:
        - bearer: []
        - session: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            example: k9fx2n1a
        - name: itemId
          in: path
          required: true
          schema:
            type: string
            example: p3qr7s2t
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                title:
                  type: string
                  maxLength: 100
                url:
                  type: string
                  format: uri
                position:
                  type: integer
                  minimum: 0
                active:
                  type: boolean
      responses:
        "200":
          description: Item updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LinktreeItem"
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Linktree or item not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    delete:
      summary: Delete an item
      tags:
        - public-api
      x-pt: apagar um item da árvore
      description: Removes the item along with the short link created for it.
      security:
        - bearer: []
        - session: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            example: k9fx2n1a
        - name: itemId
          in: path
          required: true
          schema:
            type: string
            example: p3qr7s2t
      responses:
        "204":
          description: Deleted
        "401":
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Linktree or item not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
