> ## Documentation Index
> Fetch the complete documentation index at: https://docs.formo.so/llms.txt
> Use this file to discover all available pages before exploring further.

# Batch add or update user labels

> Upsert up to 100 labels across many wallets in one request; each item carries its own `address`. Modelled on the events ingest API: rows with an invalid address (ENS names are NOT resolved here; pass a literal EVM `0x...` or Solana address) are quarantined (skipped and reported in the response) rather than failing the whole batch. A request where every row is invalid returns 400. Requires profiles:write scope.



## OpenAPI

````yaml /api/openapi.json post /v0/profiles/labels
openapi: 3.1.0
info:
  title: Formo Public API
  description: >-
    REST API for managing Formo projects, analytics, alerts, boards, charts,
    contracts, segments, and AI chat.


    **Auth.** All endpoints require a workspace API key with the appropriate
    scopes (see `x-api-scopes`).


    **Response shape.** Successful responses return the resource directly (or `{
    data: [...], total, page, size, has_more }` for paginated lists). HTTP
    status carries success/failure; there is no envelope wrapping success
    bodies.


    **Errors.** Every non-2xx response uses the `Error` envelope: `{ error: {
    code, message, doc_url, param?, details? } }`. Branch on the
    machine-readable `code` (see `ErrorCode` enum) and follow `doc_url` to the
    matching section of the [errors
    reference](https://docs.formo.so/api/errors).


    **Idempotency.** Pass an `Idempotency-Key` header on POST/PUT/PATCH/DELETE
    to make retries safe; the response is cached for 24 h and replayed on
    duplicate keys.
  version: 0.1.0
  contact:
    name: Formo
    url: https://formo.so
servers:
  - url: https://api.formo.so
    description: API Server (boards, alerts, contracts, segments, profiles, query, import)
  - url: https://events.formo.so
    description: Events Server (event ingestion)
security:
  - WorkspaceApiKey: []
tags:
  - name: Alerts
    description: Manage project alerts and notifications
  - name: Boards
    description: Manage dashboard boards
  - name: Charts
    description: Manage charts within boards
  - name: Contracts
    description: Manage blockchain contract monitoring
  - name: Segments
    description: Manage user segments
  - name: Profiles
    description: Wallet profiles and import
  - name: Query
    description: >-
      Execute SQL queries and call pre-built analytics endpoints (KPIs, top
      pages, lifecycle, retention, revenue). Requires the query:read scope.
  - name: Events
    description: Event ingestion API (events.formo.so)
paths:
  /v0/profiles/labels:
    post:
      tags:
        - Profiles
      summary: Batch add or update user labels
      description: >-
        Upsert up to 100 labels across many wallets in one request; each item
        carries its own `address`. Modelled on the events ingest API: rows with
        an invalid address (ENS names are NOT resolved here; pass a literal EVM
        `0x...` or Solana address) are quarantined (skipped and reported in the
        response) rather than failing the whole batch. A request where every row
        is invalid returns 400. Requires profiles:write scope.
      operationId: batchUpsertUserLabels
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: array
              minItems: 1
              maxItems: 100
              items:
                allOf:
                  - $ref: '#/components/schemas/UserLabelInput'
                  - type: object
                    required:
                      - address
                    properties:
                      address:
                        type: string
                        description: >-
                          Wallet address the label applies to. Literal EVM
                          (0x...) or Solana address only; ENS names are not
                          resolved in batch requests.
            examples:
              batchLabels:
                summary: Upsert labels for multiple wallets
                value:
                  - address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'
                    tag_id: vip
                    value: tier-1
                  - address: EPjFWaYbrgqCC2Qbg4EV4FjUreEMKwMn1zNbiboXXKV
                    tag_id: airdrop_eligible
                    chain_id: '1'
              historicalLabels:
                summary: Backfill a label time series ending in a removal
                description: >-
                  Each row may carry a `timestamp` to record the label at a
                  historical point in time instead of now; used to import a time
                  series of a label's value so label-based retention evaluates
                  it at the correct point. Set `_is_deleted` to 1 on a row to
                  backfill a removal at that timestamp (here the label is
                  removed on 2024-03-15). ISO-8601 datetime (UTC `Z` or with a
                  timezone offset); a future value rejects the whole batch with
                  400.
                value:
                  - address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'
                    tag_id: open_interest
                    value: high
                    timestamp: '2024-01-15T00:00:00.000Z'
                  - address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'
                    tag_id: open_interest
                    value: low
                    timestamp: '2024-02-15T00:00:00.000Z'
                  - address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'
                    tag_id: open_interest
                    _is_deleted: 1
                    timestamp: '2024-03-15T00:00:00.000Z'
      responses:
        '200':
          description: >-
            Batch processed. Returns counts of forwarded vs quarantined rows,
            with a per-row `errors` entry for each quarantined row.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BatchWriteResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
components:
  schemas:
    UserLabelInput:
      type: object
      required:
        - tag_id
      properties:
        tag_id:
          type: string
          description: >-
            Label identifier (lowercased on write). e.g. vip, airdrop_eligible,
            coinbase.verified_account
        value:
          type: string
          description: Optional label value (e.g. tier name, country code)
        chain_id:
          type: string
          description: Optional chain identifier the label applies to
        timestamp:
          type: string
          format: date-time
          description: >-
            Optional ISO-8601 event-time for the label. When provided, the label
            is recorded at this time instead of the server's current time; used
            to backfill historical values (e.g. an `open_interest` reading from
            a past week) so label-based retention can evaluate them at the right
            point in time. Must not be in the future. Defaults to server time
            when omitted.
        _is_deleted:
          type: integer
          enum:
            - 0
            - 1
          description: >-
            Optional tombstone flag for backfilled removals. `1` records the row
            as a soft-delete (label removed) instead of a live value; pair it
            with a past `timestamp` to express "label removed at past time T" so
            point-in-time retention drops the wallet from that week. The
            future-timestamp guard still applies. Defaults to `0` (a live label)
            when omitted.
    BatchWriteResponse:
      type: object
      description: >-
        Acknowledgement for a batch write. `successful_rows` counts rows
        accepted and forwarded to ingest after a 2xx (ingestion is
        async/eventually-consistent; there is no follow-up read).
        `quarantined_rows` counts rows skipped for an invalid address or no
        valid keys; `errors` (omitted when nothing was quarantined) maps each
        skipped row back to its request index.
      required:
        - successful_rows
        - quarantined_rows
      properties:
        successful_rows:
          type: integer
          description: Number of rows accepted and forwarded to ingest.
        quarantined_rows:
          type: integer
          description: Number of rows skipped (invalid address, or no valid keys).
        errors:
          type: array
          description: One entry per quarantined row. Omitted when quarantined_rows is 0.
          items:
            type: object
            required:
              - index
              - reason
            properties:
              index:
                type: integer
                description: Zero-based index of the row in the request array.
              address:
                type: string
                description: The address as submitted, echoed back.
              reason:
                type: string
                description: Why the row was quarantined.
    Error:
      type: object
      description: >-
        Standard error envelope returned by every public API endpoint for any
        non-2xx response. The HTTP status code carries success/failure; the body
        provides a machine-readable `code`, a human-readable `message`, and a
        `doc_url` pointing at the matching section of the docs so agents can
        fetch context on the fly.
      properties:
        error:
          type: object
          required:
            - code
            - message
            - doc_url
          properties:
            code:
              $ref: '#/components/schemas/ErrorCode'
            message:
              type: string
              description: >-
                Human-readable error description. Wording may change between
                releases, so branch on `code`, not `message`.
            doc_url:
              type: string
              format: uri
              description: >-
                Link to the matching section of the errors reference at
                https://docs.formo.so/api/errors.
            param:
              type: string
              description: >-
                When the error pertains to a specific request field, the dotted
                path to that field (e.g. `body.trigger_filters.0.value`).
            details:
              type: object
              additionalProperties: true
              description: >-
                Code-specific extra context. For `INVALID_VALIDATION_REQUEST`
                this is a `{ fieldPath: message }` map of every Zod validation
                failure.
      required:
        - error
    ErrorCode:
      type: string
      description: >-
        Stable, enumerated error codes. New codes may be added in any release;
        clients should treat unknown codes as the closest matching HTTP status
        family.
      enum:
        - INTERNAL_SERVER_ERROR
        - INVALID_VALIDATION_REQUEST
        - UNAUTHORIZED
        - BAD_REQUEST
        - FORBIDDEN
        - NOT_FOUND
        - CONFLICT
        - INVALID_CHAIN_ID
        - CONTEXT_LIMIT_EXCEEDED
        - SERVICE_UNAVAILABLE
        - TOO_MANY_REQUESTS
        - IDEMPOTENCY_IN_PROGRESS
        - INVALID_IDEMPOTENCY_KEY
  responses:
    BadRequest:
      description: >-
        The request was rejected. `code` is either `INVALID_VALIDATION_REQUEST`
        (Zod schema mismatch; `details` carries a `{ fieldPath: message }` map)
        or `BAD_REQUEST` (semantic validation failure outside Zod, e.g.
        mismatched IDs, business-rule violations). Branch on `code`, not status,
        to tell the two apart.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            validation:
              summary: Zod schema mismatch
              value:
                error:
                  code: INVALID_VALIDATION_REQUEST
                  message: Invalid request data
                  doc_url: https://docs.formo.so/api/errors#invalid_validation_request
                  details:
                    body.name: String must contain at least 1 character(s)
            semantic:
              summary: Semantic validation failure
              value:
                error:
                  code: BAD_REQUEST
                  message: Target board must be different from the current board
                  doc_url: https://docs.formo.so/api/errors#bad_request
    Unauthorized:
      description: Missing or invalid API key.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: UNAUTHORIZED
              message: Invalid API key
              doc_url: https://docs.formo.so/api/errors#unauthorized
    Forbidden:
      description: The API key is valid but lacks the required scope for this endpoint.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: FORBIDDEN
              message: 'API key missing required scope: alerts:write'
              doc_url: https://docs.formo.so/api/errors#forbidden
    NotFound:
      description: >-
        The requested resource does not exist or is not visible to this API
        key's workspace.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: NOT_FOUND
              message: Alert not found
              doc_url: https://docs.formo.so/api/errors#not_found
    TooManyRequests:
      description: >-
        Per-workspace rate limit exceeded. Inspect the `RateLimit-*` response
        headers and back off.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: TOO_MANY_REQUESTS
              message: Too many requests
              doc_url: https://docs.formo.so/api/errors#too_many_requests
    InternalServerError:
      description: >-
        An unexpected error occurred on the server. The error has been logged;
        retry with exponential backoff.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: INTERNAL_SERVER_ERROR
              message: Internal Server Error
              doc_url: https://docs.formo.so/api/errors#internal_server_error
  securitySchemes:
    WorkspaceApiKey:
      type: http
      scheme: bearer
      description: >-
        Workspace API key (e.g. `formo_xxx`). Create one in the Formo dashboard
        under Team Settings > API Keys.

````