Skip to content

Groups API

sgofferj edited this page Aug 26, 2026 · 4 revisions

Group API (Channel Subscriptions)

Accessor: server.groups

Reference: https://docs.tak.gov/api/takserver#tag/groups-api

In TAK Server 5.x, "channels" are the classic file/LDAP auth groups exposed to end users as a selectable subscription mechanism. A client "subscribes to a channel" by changing its active group set — no new CoT semantics are involved. The server routes broadcast CoT to a client only when the sender's active IN groups intersect the receiver's active OUT groups. Directions are from the group/channel's point of view: an IN subscription lets you send into the channel (feed producers carry this - an ADSB feeder holds the matching *_WRITE entitlement), an OUT subscription means you receive the channel's traffic.

Entitlements, channels and subscriptions — know the difference

This is where most TAK API confusion happens, so let's sort it out first. There are three distinct scopes, served by three different endpoints:

Scope What it tells you Where it lives
Entitlements What a user may subscribe to. Managed via the User Account Management API (groupList, groupListIN, groupListOUT). Auth file / user database
Available channels Which of those entitlements materialize as toggleable channels. GET /Marti/api/groups/all
Active subscriptions What the user is subscribed to right now. GET /Marti/api/groups/user?username=...

Note

The active flag in get_all_groups() does not reflect the current subscription state — treat that endpoint as a discovery/catalog listing only. For actual subscription state use get_active_groups(), which reads the subscription endpoint.

Verified live on 5.7-RELEASE-43-HEAD (2026-08-25): querying the subscription endpoint for another user returns the persisted state instantly. Querying it with your own username returns your connection-bound state, which is empty for plain REST clients without a messaging session. If you wonder why your own subscriptions look empty although ATAK shows them fine — that's why.

Prerequisites

The subscription helpers need to know whose subscriptions they are dealing with. Pass the username when constructing the server (for certificate auth that's the certificate's CN):

srv = Server("tak.example.com", "client.pem", "client.key", username="myuser")

Alternatively, pass username="..." explicitly to each helper call.


get_all_groups()

Usage: await server.groups.get_all_groups(use_cache=False, send_latest_sa=False)

Returns all channels visible to the authenticated user. Admins see everything; regular users see their entitled channels.

API: GET /Marti/api/groups/all

Parameter Type Description
use_cache bool Use the server-side group cache. Default False.
send_latest_sa bool Ask for latest SA data along with the group list. Default False.

Returns: (status_code, data)data is the unwrapped list of group dicts: name, direction (IN/OUT), created, type, bitpos, active.

Note

The list may contain duplicate names with different creation dates — stale entries survive group edits, including variants padded with leading spaces (" CHANNEL"). The helper functions collapse those for you and always prefer the unpadded spelling.

Example:

status, groups = await srv.groups.get_all_groups()
for g in groups:
    print(g["name"], g["direction"], g["active"])

get_groups_for_user()

Usage: await server.groups.get_groups_for_user(username)

Returns a user's active channel subscriptions — this is the readback of set_active_groups() and the source of truth the helpers use. Do not confuse it with server.user.get_groups_for_user(), which returns raw entitlements.

API: GET /Marti/api/groups/user

Parameter Type Description
username str User to query

Returns: (status_code, data) — list of {name, direction, created, type, bitpos, active} dicts. Empty when the user has no subscriptions.

get_group()

Usage: await server.groups.get_group(name, direction)

Returns a single channel entry by name and direction.

API: GET /Marti/api/groups/{name}/{direction}

Parameter Type Description
name str Channel/group name
direction str "IN" or "OUT" (case-insensitive)

Returns: (status_code, data) — single group dict.

set_active_groups()

Usage: await server.groups.set_active_groups(groups, client_uid=None)

Sets the complete active subscription set of the authenticated user. Semantics are absolute, not incremental: whatever you omit becomes unsubscribed. Takes effect immediately on the user's CoT connection.

API: PUT /Marti/api/groups/active

Parameter Type Description
groups tuples or dicts The complete desired set as (name, direction) tuples or plain dicts
client_uid str or None Optional client UID to bind the change to a specific messaging session

Returns: (status_code, response)

Warning

This will fail you silently if you are not careful. An empty list is accepted with HTTP 200 but ignored — you cannot unsubscribe from your last remaining channel this way (verified live). And since the semantics are absolute, always build your new set from get_active_groups(), never from scratch.

Example:

# Subscribe to two channels - anything else gets unsubscribed!
status, _ = await srv.groups.set_active_groups([
    ("WOLF_ADSB", "OUT"),
    ("WOLF_Family", "OUT"),
])

set_active_groups_bits()

Usage: await server.groups.set_active_groups_bits(bits, client_uid=None)

Bitmask variant of set_active_groups(): instead of names, you pass the bitpos values of the groups to activate. Same absolute semantics.

API: PUT /Marti/api/groups/activebits

Parameter Type Description
bits list[int] Bit positions (each group's bitpos field)
client_uid str or None Optional client UID

Returns: (status_code, response)

set_active_groups_force()

Usage: await server.groups.set_active_groups_force(username, groups)

Admin-forced activation of channels for another user — bypasses user opt-out. Requires admin rights. Absolute semantics for the target user's forced set.

API: PUT /Marti/api/groups/activeForce

Parameter Type Description
username str Target user
groups tuples or dicts The complete forced set

Returns: (status_code, response)

wait_for_group_update() and wait_for_group_update_until()

async def wait_for_group_update(username: str) -> tuple[int, Any]
async def wait_for_group_update_until(username: str, timeout: float) -> tuple[int, Any]

Long-poll: blocks until an admin alters group assignments for the given user server-side. This is how clients refresh their channel list without reconnecting. The _until() variant bounds the wait and raises TimeoutError when nothing happens within timeout seconds — you will want that variant in any production code.

API: GET /Marti/api/groups/update/{username}

Note

Verified live on 5.7-RELEASE-43-HEAD: a limited (non-admin) client certificate gets HTTP 403 on this endpoint. Poll as an admin or give the client the required role.

get_group_cache_enabled()

Usage: await server.groups.get_group_cache_enabled()

Returns whether the server-side group cache is enabled.

API: GET /Marti/api/groups/groupCacheEnabled

Returns: (status_code, data)data is a boolean.

get_ldap_groups() / get_ldap_group_members()

LDAP directory queries — only useful on LDAP-backed servers. On a plain file-auth server they answer, but return nothing meaningful.

async def get_ldap_groups(group_name_filter: str) -> tuple[int, Any]
async def get_ldap_group_members(group_name_filter: list[str]) -> tuple[int, Any]

Helper functions

These build on the raw wrappers above and save you from the absolute- semantics foot-gun. All of them accept an optional username= argument; if omitted, they use the username the Server was constructed with.

get_active_groups()

async def get_active_groups(username: str | None = None) -> list[dict]

Returns [{"name": ..., "direction": ...}, ...] — the current subscriptions, read from the subscription endpoint. Feed this straight back into set_active_groups():

current = await srv.groups.get_active_groups()
updated = current + [{"name": "WOLF_ADSB", "direction": "OUT"}]
await srv.groups.set_active_groups(updated)

Warning

If the subscription readback fails (non-200 response or an unparsable error body), get_active_groups() raises ValueError with a clear message instead of returning bogus data. The same applies to is_subscribed(), get_channels() and channel_exists().

subscribe() / subscribe_many()

async def subscribe(name, directions=("IN", "OUT"), username=None)
async def subscribe_many(names, directions=("IN", "OUT"), username=None)

Read-modify-write around set_active_groups(): activates the requested directions of the given channel(s), leaves everything else untouched. Raises ValueError for channels that don't exist or aren't visible to the user — before anything is written.

await srv.groups.subscribe("WOLF_ADSB")                    # both directions
await srv.groups.subscribe("WOLF_Family", directions=["OUT"])
await srv.groups.subscribe_many(["WOLF_ADSB", "WOLF_Family"])

unsubscribe() / unsubscribe_many()

Mirror images of subscribe()/subscribe_many(): deactivates the requested directions, keeps everything else. Unknown channels are ignored.

await srv.groups.unsubscribe("WOLF_ADSB")

is_subscribed()

async def is_subscribed(name, direction=None, username=None) -> bool

Checks the subscription state (not just availability). By default any subscribed direction counts; narrow it with direction="OUT" etc.

if not await srv.groups.is_subscribed("WOLF_ADSB"):
    await srv.groups.subscribe("WOLF_ADSB")

get_channels()

Dev-friendly catalog view: collapses duplicate/stale entries into one record per channel —

{"name": "channel-a",
 "type": "SYSTEM",
 "bitpos": 3,
 "directions": {"IN": true, "OUT": false}}

Newest entry wins on conflicts, unpadded spelling preferred. Remember: availability, not subscription state.

channel_exists()

async def channel_exists(name: str) -> bool

True when the channel is visible to this user. Case-sensitive, but tolerates whitespace-padded duplicates in the server's data.


Server quirks worth knowing (verified live, 5.7-RELEASE-43-HEAD)

  1. Basic-auth enrollment does not fall back to file users. With auth default=ldap, password-based operations (e.g. the /Marti/api/tls/signClient enrollment flow) authenticate against LDAP first and there is no fallback to file-based accounts — the log shows LdapAuthenticator ... Invalid Credentials and the request dies with 401, even though the same credentials work for the User Account Management API. Plan provisioning accordingly: either create your clients in LDAP or mint certificates via the server's CA tooling.
  2. File-user channels materialize as OUT only. Even when a user is entitled to a group as BOTH, the channel layer lists a single OUT entry; explicit IN activations are silently dropped. IN-direction subscriptions matter for LDAP-backed setups.
  3. Stale entries never die. Renamed/deleted groups linger in /groups/all (including whitespace-padded name variants). Always normalize names (.strip()) before comparing — the helpers do.
  4. Freshly created users take a moment. Right after creation, group lookups can briefly fail server-side ("User lookup failed"); writes still persist. If you script user creation + subscription changes, allow a few seconds between steps.