Building Groups via API

Last updated: April 17, 2026

Overview

The Group Building API lets you programmatically create groups in Unwrap. You provide a natural language description of the feedback theme you want to track, and Unwrap's NLP system will classify matching feedback and build the group in the background.

This is an asynchronous API — the mutation returns immediately with a groupId, and the group build completes in the background (typically 5 – 60 minutes, depending on the size of your feedback dataset). You can monitor progress in the Unwrap UI.

You'll need an API access token and your team ID before making requests. See the Getting Started guide for setup instructions.

Create a Group

Mutation

mutation CreateGroup($teamId: Int!$title: String!$description: String!$parentGroupId: BigInt
  $scopeToParentEntries: Boolean
  $filterNode: FilterNode
) {
  buildGroupAsync(
    teamId: $teamId
    title: $title
    description: $description
    parentGroupId: $parentGroupId
    scopeToParentEntries: $scopeToParentEntries
    filterNode: $filterNode) {
    groupId
  }}

Parameters

Parameter

Type

Required

Description

teamId

Int

Yes

The ID of the team (View) to create the group in.

title

String

Yes

The display title for the group (e.g. "Pricing complaints").

description

String

Yes

A natural language description of the feedback theme. This drives the classifier — the more specific, the better.

parentGroupId

BigInt

No

If provided, the new group is attached as a child of this group in the taxonomy. Required when using scopeToParentEntries.

scopeToParentEntries

Boolean

No

When true (with a parentGroupId), the new group is built as a filtered child group whose candidate entries are restricted to the parent group's entries. See below.

filterNode

FilterNode

No

A JSON-encoded filter AST string that restricts the candidate entries considered during the build. Currently only supports metadata (segment) filters. See below.

Tips for writing a good description

  • The classifier reads customer feedback — phrase your description the way customers would describe the issue.

  • Be explicit about what to match and what to exclude. For example:

    DO capture customers complaining about price being too high, expensive, or not worth the cost. DO NOT capture customers complaining about billing issues unrelated to pricing.

Response

{"data": {
    "buildGroupAsync": {
      "groupId": "98765",
    }}}
  • groupId — the ID of the new group. Use this to deep-link into the Unwrap UI.


parentGroupId — attaching to the taxonomy

Passing parentGroupId links the new group as a child of an existing group in your taxonomy. The child will appear nested under the parent in the Unwrap UI.

parentGroupId is also a prerequisite for scopeToParentEntries — if you want the child to be scoped to its parent's entries (below), you must pass both.

scopeToParentEntries — creating a filtered child group

Set scopeToParentEntries: true together with a parentGroupId to create a filtered child group. When this flag is set, Unwrap builds the new group as a filter group whose candidate feedback entries are restricted to the entries already in the parent group. The classifier then runs against that scoped set.

This is the right option when you want to break a broad parent theme into sub-themes — e.g., parent group "Pricing complaints" → child group "Annual plan pricing complaints" that only considers entries already classified into "Pricing complaints".

Notes:

  • scopeToParentEntries: true without parentGroupId is ignored (it's a no-op).

  • You can combine scopeToParentEntries with filterNode to further narrow the scope (parent's entries AND matching the metadata filter).

filterNode — restricting the build with a metadata filter

filterNode takes a JSON-encoded string representing a filter AST. Currently, buildGroupAsync only accepts segment (metadata) field filters — i.e., fields of the form:

Entry.Segment.{segmentGroupId}.value

{segmentGroupId} is the numeric ID of the segment group in Unwrap (you can find this in the Segments admin page, or in the URL when editing a segment). Any other field name will be rejected with:

Filter groups only support segment fields (Entry.Segment.{id}.value). Got: "<fieldName>"

AST shape

A filter node is either a statement (a single comparison) or a collection (a boolean combination of nodes).

Statement:

{"type": "statement","fieldName": "Entry.Segment.42.value","operator": "==","value": "enterprise"}

Supported operators for segment values:

OperatorMeaning

==

Equal

!=

Not equal

in

Value is in a JSON array, e.g. "[\"a\", \"b\"]"

!in

Value is not in a JSON array

exists

Segment has any value (no value needed)

not_exists

Segment has no value (no value needed)

>=

Greater than or equal (numeric segments only)

<=

Less than or equal (numeric segments only)

Collection:

{"type": "collection","operator": "AND","items": [
    {
      "type": "statement",
      "fieldName": "Entry.Segment.42.value",
      "operator": "==",
      "value": "enterprise"
    },
    {
      "type": "statement",
      "fieldName": "Entry.Segment.17.value",
      "operator": "in",
      "value": "[\"US\", \"CA\"]"
    }]}

operator on a collection is either "AND" or "OR", and collections can be nested inside each other.

Important: send filterNode as a stringified JSON

The FilterNode scalar accepts the AST as a JSON string, not a raw object. Always JSON.stringify(...) the AST before putting it in your variables payload.

Simple metadata-filter example

Only consider feedback from enterprise customers when building the group:

{"teamId": 123,"title": "Pricing complaints (enterprise)","description": "DO capture enterprise customers complaining about price being too high, expensive, or not worth the cost. DO NOT capture billing issues unrelated to pricing.","filterNode": "{\"type\":\"statement\",\"fieldName\":\"Entry.Segment.42.value\",\"operator\":\"==\",\"value\":\"enterprise\"}"}

curl Example (with parent + scope + filter)

This builds a sub-group under an existing parent group, scoped to the parent's entries, and further filtered to the enterprise segment value:

curl -s -X POST https://data.api.production.unwrap.ai/graphql \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -d '{
    "query": "mutation CreateGroup($teamId: Int!, $title: String!, $description: String!, $parentGroupId: BigInt, $scopeToParentEntries: Boolean, $filterNode: FilterNode) { buildGroupAsync(teamId: $teamId, title: $title, description: $description, parentGroupId: $parentGroupId, scopeToParentEntries: $scopeToParentEntries, filterNode: $filterNode) { jobId groupId conversationId } }",
    "variables": {
      "teamId": 123,
      "title": "Annual plan pricing complaints (enterprise)",
      "description": "DO capture customers complaining about the annual plan being too expensive or not worth the cost. DO NOT capture billing or refund issues.",
      "parentGroupId": "55555",
      "scopeToParentEntries": true,
      "filterNode": "{\"type\":\"statement\",\"fieldName\":\"Entry.Segment.42.value\",\"operator\":\"==\",\"value\":\"enterprise\"}"
    }
  }'

Full Python Example

import json
import os
import requests

ACCESS_TOKEN = os.getenv("UNWRAP_ACCESS_TOKEN")
URL = "https://data.api.production.unwrap.ai/graphql"
HEADERS = {"Authorization": f"Bearer {ACCESS_TOKEN}"}

MUTATION = """
    mutation CreateGroup(
        $teamId: Int!
        $title: String!
        $description: String!
        $parentGroupId: BigInt
        $scopeToParentEntries: Boolean
        $filterNode: FilterNode
    ) {
        buildGroupAsync(
            teamId: $teamId
            title: $title
            description: $description
            parentGroupId: $parentGroupId
            scopeToParentEntries: $scopeToParentEntries
            filterNode: $filterNode
        ) {
            jobId
            groupId
            conversationId
        }
    }
"""def create_group(
    team_id: int,
    title: str,
    description: str,
    parent_group_id: str | None = None,
    scope_to_parent_entries: bool = False,
    filter_node: dict | None = None,
) -> dict:
    """Kick off an async group build. Returns {jobId, groupId, conversationId}."""
    variables = {
        "teamId": team_id,
        "title": title,
        "description": description,
        "parentGroupId": parent_group_id,
        "scopeToParentEntries": scope_to_parent_entries,
        "filterNode": json.dumps(filter_node) if filter_node is not None else None,
    }
    response = requests.post(URL, json={"query": MUTATION, "variables": variables}, headers=HEADERS)
    response.raise_for_status()
    data = response.json()
    if "errors" in data:
        raise Exception(f"GraphQL errors: {data['errors']}")
    return data["data"]["buildGroupAsync"]


# Usage: create a sub-group under an existing "Pricing complaints" group,# scoped to its entries and further restricted to the "enterprise" segment.
result = create_group(
    team_id=123,
    title="Annual plan pricing complaints (enterprise)",
    description=(
        "DO capture customers complaining about the annual plan being too "
        "expensive or not worth the cost. DO NOT capture billing or refund issues."
    ),
    parent_group_id="55555",
    scope_to_parent_entries=True,
    filter_node={
        "type": "statement",
        "fieldName": "Entry.Segment.42.value",
        "operator": "==",
        "value": "enterprise",
    },
)
print(f"Group created. ID: {result['groupId']} (job: {result['jobId']})")