What you will build

An agency, a group with several brands, a platform sending for its customers: one account ends up holding dozens of domains and IPs that belong to different owners. Resource groups put a name on each set, and most read endpoints take a group filter, so every figure can be read per brand or per client. The API lets you:

  • mirror your client list: one group per client, created and filled from your CRM or your billing;
  • produce one report per client by calling the same endpoints with a different group;
  • onboard domains faster by claiming those your ESP or Google Postmaster already know about.

A group is a filter, not a permission: every member of the account still sees every domain. To separate access for real, use separate accounts.

Endpoint reference This guide shows which calls to chain and what to read in each response. Every field is documented in the API reference, under Groups and Domains, generated from the API itself.

Before you start

export SA_KEY="sa_live_your_key"
export SA_API="https://senderaudit.com/api/v1"
import os, requests

API = "https://senderaudit.com/api/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['SA_KEY']}"

Step 1: create the groups

POST /groups creates a group from a name, unique on the account.

curl -s -X POST "$SA_API/groups" \
  -H "Authorization: Bearer $SA_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Acme Retail"}'
group = session.post(f"{API}/groups", json={"name": "Acme Retail"}).json()
group_id = group["id"]

GET /groups lists them with their member counts, how many your plan allows (limit) and how many are active (active_count). The plan limit counts active groups: PATCH /groups/{id} with {"is_active": false} archives a group, for a client who left, without losing it, and frees its place.

Step 2: fill them

Two ways, depending on where your source of truth sits.

From the group: PUT /groups/{id}/members replaces the whole membership with the lists you send. Idempotent, which makes it the right call for a sync job.

session.put(f"{API}/groups/{group_id}/members", json={
    "domains": ["6f1c2b9e-3d4a-4e8f-9a51-0c7d2e8b4f13", "0b7e5a2c-..."],
    "ips": ["c2d9e1f0-..."],
})

From the domain or the IP: PUT /groups/membership/domain/{domain_id} (and its IP twin) sets the groups of one asset, as a list of group ids. A domain can belong to several groups.

Everything is identified by its id, the UUID returned by GET /domains, GET /ips and GET /groups. GET /groups/assignable returns, in one call, every verified domain and IP with the groups it already belongs to: the starting point to compare with your own list.

A sync job

# clients = {"Acme Retail": ["acme.com", "acme-shop.com"], ...} from your CRM
assignable = session.get(f"{API}/groups/assignable").json()
domain_ids = {d["label"]: d["id"] for d in assignable["domains"]}
groups = {g["name"]: g["id"] for g in session.get(f"{API}/groups").json()["groups"]}

for client, domains in clients.items():
    gid = groups.get(client) or session.post(f"{API}/groups", json={"name": client}).json()["id"]
    wanted = [domain_ids[d] for d in domains if d in domain_ids]
    session.put(f"{API}/groups/{gid}/members", json={"domains": wanted, "ips": []})

Step 3: read per group

Pass the group’s id as group to scope the figures:

EndpointScoped by group
GET /overviewScore, KPIs, actions and assets of the group only
GET /domains, GET /ipsThe group’s members
GET /dmarc/... (overview, stats, daily, domains-summary, senders, reports, reporters)Reports of the group’s domains
GET /tlsrpt/... (summary, stats, reports)Reports of the group’s domains
GET /recommendationsRecommendations on the group’s domains
for name, gid in groups.items():
    ov = session.get(f"{API}/overview", params={"group": gid}).json()
    dmarc = session.get(f"{API}/dmarc/overview", params={"group": gid, "days": 30}).json()
    print(f'{name:20} score {ov["global"]["score"]}  DMARC compliance {dmarc["kpis"]["compliance"]}')
An unknown group falls back to the whole account A group id that does not exist, belongs to another account or points to an archived group is ignored: the call answers with the figures of the whole account, without an error. It keeps a stale filter from breaking a screen, but in a per-client report it would show one client everybody’s data. Check the id against GET /groups, and that the group is active, before using it.

domain wins over group when you pass both.

Going further

Domains your integrations discovered

When an ESP or Google Postmaster Tools is connected (see API: analyse your sending performance), the sending domains they know about become candidates. GET /domains/candidates lists them, each with where it came from (discovered_from, provider_label) and the DKIM selectors found.

The claim answers with the domain’s id as domain_id: pass it straight to PUT /groups/membership/domain/{domain_id} to add it to its group.

Quotas

Every client added eats into the same allowances. Check them before a batch rather than halfway through:

EndpointWhat it counts
GET /domains/quotaMonitored hosts, and the plan’s limits
GET /ips/quotaMonitored IP addresses
GET /dmarc/quotaDMARC messages this month, when the plan has a limit
GET /integrations/esp/quotaESP events over the period
GET /auth/me/scan-usageScans today

Deleting a group

DELETE /groups/{id} deletes the group and its memberships. The domains and IPs stay, with their history.

Limits and errors

SituationAnswer
Plan without resource groups403
Plan’s limit of active groups reached400
Group name already used409
Group, or single domain or IP, id unknown on the account404
Unknown domain or IP in a members list400, nothing is changed
Too many calls on an endpoint429, with a Retry-After header
Missing or invalid key401