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

# Sub-account settings API: recipes

> Runnable recipes for the sub-account settings surface — read and write the effective notification-email sender, drive sender verification, seed per-subaccount notification recipients, scope the channel-family allowlist, and walk the inheritance chain when the effective sender resolves somewhere you did not expect.

# Sub-account settings API: recipes

The OpenAPI pages — [Subaccounts API](/api-reference/subaccounts) and the [endpoint reference](/api-reference/endpoints/subaccounts) — document each operation in isolation. This cookbook chains the settings operations on `/subaccounts/:id` into the multi-step loops a reseller actually runs: read the effective sender, write a custom one, verify it, clear it, seed recipients, scope channels, and audit the inheritance chain. Everything here is a tenant-owned control: your parent organization owns the sub-accounts, the sender identities, and the allowlists; Orbit resolves and enforces them.

Base URL is `https://api.orbit.devotel.io/api/v1` throughout. Every response arrives in the shared envelope: a `data` object plus `meta.request_id` / `meta.timestamp`.

```bash cURL theme={null}
export ORBIT_API_BASE="https://api.orbit.devotel.io/api/v1"
export ORBIT_TOKEN="<your session or API token>"
```

The settings routes are not covered by a typed SDK helper yet, so every example below is raw HTTP — `fetch` in TypeScript, `requests` in Python — and runs from any client. If you already use the Node or Python SDK, the same paths work through its escape hatch (`client.request(...)`), since these routes take no SDK-specific shaping.

## 0. Auth, roles, and the common path shape

Authenticate with a bearer token from an owner or admin of the **parent** organization. The settings plane (`notification-email`, recipients, allowlist, branding, pricing) is parent-admin territory: a token whose roles only cover a child organization gets `403` when it targets a sibling or its own parent. One exception mirrors the branding surface: a child org's own admin/owner may read and write its **own** settings by passing its own organization id as `:id`.

Unknown or invisible sub-account ids return `404`, not `403` — the controller does not leak which child orgs exist to a caller without visibility. Treat a `404` on a id you just created as "wrong token", not "wrong id".

These routes manage configuration, not message traffic, so there is no pagination to handle; each `GET` returns the full settings object for one sub-account.

| Status | Meaning here |
| - | - |
| `401` | Missing or expired bearer token. |
| `403` | Token lacks owner/admin on the parent org for this `:id`. |
| `404` | `:id` does not exist, or is hidden from this caller. |
| `422` | Body failed validation — see [Troubleshooting](#troubleshooting) for the top entries. |

## 1. Read the effective notification-email config

`GET /subaccounts/:id/notification-email` returns two layers: what this org **configured**, and what actually **sends** after inheritance resolution. The `effective.source` enum tells you where the resolver landed: `subaccount` (this org's verified override), `ancestor` (the nearest parent with a verified override), or `platform` (the Orbit default sender).

```bash cURL theme={null}
curl -sS "$ORBIT_API_BASE/subaccounts/org_child_9f8a/notification-email" \
  -H "Authorization: Bearer $ORBIT_TOKEN"
```

A child that set and verified its own sender:

```json 200 theme={null}
{
  "data": {
    "from_name": "Acme Support",
    "from_address": "support@acme.example",
    "verified": true,
    "effective": {
      "from_address": "support@acme.example",
      "from_name": "Acme Support",
      "source": "subaccount",
      "resolved_from_organization_id": null
    }
  },
  "meta": { "request_id": "req_set001", "timestamp": "2026-10-13T09:00:00Z" }
}
```

Read the two layers independently. `configured` (the top-level `from_name` / `from_address` / `verified`) is what a `PUT` wrote on **this** org; it may be all `null` on a fresh child. `effective` is what the notification emitter will actually use, resolved sub-account → parent → platform. When the effective sender comes from somewhere else, `effective.source` says where and `effective.resolved_from_organization_id` is the chain pointer — the organization id whose config won, or `null` when the org itself (or the platform default) won. A child inheriting from its parent therefore shows `configured.from_address: null` next to `effective.source: "ancestor"` and `resolved_from_organization_id: "org_parent_1"`.

```typescript TypeScript theme={null}
const res = await fetch(
  `${ORBIT_API_BASE}/subaccounts/org_child_9f8a/notification-email`,
  { headers: { Authorization: `Bearer ${ORBIT_TOKEN}` } },
);
const { data } = (await res.json()) as {
  data: {
    from_name: string | null;
    from_address: string | null;
    verified: boolean;
    effective: {
      from_address: string;
      from_name: string;
      source: "subaccount" | "ancestor" | "platform";
      resolved_from_organization_id: string | null;
    };
  };
};
console.log(data.effective.source, data.effective.resolved_from_organization_id);
```

```python Python theme={null}
import os
import requests

base = os.environ["ORBIT_API_BASE"]
token = os.environ["ORBIT_TOKEN"]
resp = requests.get(
    f"{base}/subaccounts/org_child_9f8a/notification-email",
    headers={"Authorization": f"Bearer {token}"},
    timeout=30,
)
resp.raise_for_status()
data = resp.json()["data"]
print(data["effective"]["source"], data["effective"]["resolved_from_organization_id"])
```

## 2. Set a custom sender identity on a child

`PUT /subaccounts/:id/notification-email` writes the override. The write persists immediately, but the configured address only becomes the effective sender once its sending domain passes DKIM/SPF — the same verification evidence the email-senders surface tracks. Until then the resolver keeps using the ancestor or platform sender, so a successful `PUT` can still show `effective.source: "ancestor"`. That is the designed activation gate, not a failed write.

```bash cURL theme={null}
curl -sS -X PUT "$ORBIT_API_BASE/subaccounts/org_child_9f8a/notification-email" \
  -H "Authorization: Bearer $ORBIT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "from_name": "Acme Support", "from_address": "support@acme.example" }'
```

```json 200 theme={null}
{
  "data": {
    "from_name": "Acme Support",
    "from_address": "support@acme.example",
    "verified": false,
    "effective": {
      "from_address": "billing@acme-parent.example",
      "from_name": "Acme Billing",
      "source": "ancestor",
      "resolved_from_organization_id": "org_parent_1"
    }
  },
  "meta": { "request_id": "req_set002", "timestamp": "2026-10-13T09:05:00Z" }
}
```

Then poll until `verified` flips and the resolver promotes the override:

```bash cURL theme={null}
while true; do
  state=$(curl -sS "$ORBIT_API_BASE/subaccounts/org_child_9f8a/notification-email" \
    -H "Authorization: Bearer $ORBIT_TOKEN")
  echo "$state" | jq -e '.data.verified and (.data.effective.source == "subaccount")' \
    && break
  sleep 30
done
```

```typescript TypeScript theme={null}
async function readSender(id: string) {
  const res = await fetch(`${ORBIT_API_BASE}/subaccounts/${id}/notification-email`, {
    headers: { Authorization: `Bearer ${ORBIT_TOKEN}` },
  });
  if (!res.ok) throw new Error(`GET notification-email: ${res.status}`);
  return (await res.json()).data;
}

const put = await fetch(
  `${ORBIT_API_BASE}/subaccounts/org_child_9f8a/notification-email`,
  {
    method: "PUT",
    headers: {
      Authorization: `Bearer ${ORBIT_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      from_name: "Acme Support",
      from_address: "support@acme.example",
    }),
  },
);
if (!put.ok) throw new Error(`PUT notification-email: ${put.status}`);

for (;;) {
  const cfg = await readSender("org_child_9f8a");
  if (cfg.verified && cfg.effective.source === "subaccount") break;
  await new Promise((r) => setTimeout(r, 30_000));
}
```

```python Python theme={null}
import time

put = requests.put(
    f"{base}/subaccounts/org_child_9f8a/notification-email",
    headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
    json={"from_name": "Acme Support", "from_address": "support@acme.example"},
    timeout=30,
)
put.raise_for_status()

while True:
    cfg = requests.get(
        f"{base}/subaccounts/org_child_9f8a/notification-email",
        headers={"Authorization": f"Bearer {token}"},
        timeout=30,
    ).json()["data"]
    if cfg["verified"] and cfg["effective"]["source"] == "subaccount":
        break
    time.sleep(30)
```

A body that fails validation returns `422` with the offending field named:

```json 422 theme={null}
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "from_address must be a valid email address"
  },
  "meta": { "request_id": "req_set003", "timestamp": "2026-10-13T09:06:00Z" }
}
```

Nothing persists on a `422`, so fixing the body and retrying is always safe. The top validation entries are: `from_address` not a syntactically valid email, and `from_name` / `from_address` sent as non-string, non-null types. Send `null`, not `""`, when you mean "clear" (next recipe).

## 3. Clear the custom sender to restore the parent chain

`PUT` with `from_address: null` clears the whole override — the resolver then falls back to the nearest ancestor with a verified sender, or the platform default when no ancestor has one. Send `from_name: null` alongside; a name without an address is not a sender.

```bash cURL theme={null}
curl -sS -X PUT "$ORBIT_API_BASE/subaccounts/org_child_9f8a/notification-email" \
  -H "Authorization: Bearer $ORBIT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "from_name": null, "from_address": null }'
```

```json 200 theme={null}
{
  "data": {
    "from_name": null,
    "from_address": null,
    "verified": false,
    "effective": {
      "from_address": "billing@acme-parent.example",
      "from_name": "Acme Billing",
      "source": "ancestor",
      "resolved_from_organization_id": "org_parent_1"
    }
  },
  "meta": { "request_id": "req_set004", "timestamp": "2026-10-13T09:10:00Z" }
}
```

The response is computed after the write, so `effective` in the `PUT` response already shows the fallback the resolver will use from now on — no follow-up `GET` needed to confirm a clear.

```typescript TypeScript theme={null}
await fetch(`${ORBIT_API_BASE}/subaccounts/org_child_9f8a/notification-email`, {
  method: "PUT",
  headers: {
    Authorization: `Bearer ${ORBIT_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ from_name: null, from_address: null }),
});
```

```python Python theme={null}
requests.put(
    f"{base}/subaccounts/org_child_9f8a/notification-email",
    headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
    json={"from_name": None, "from_address": None},
    timeout=30,
).raise_for_status()
```

## 4. Per-subaccount notification recipients

By default a child's operational notifications (rate-sheet digests, low-balance alerts) go to the inherited recipients. `settings.notification_recipients` overrides them per sub-account with two lists: `rate_sheet_emails` and `balance_alert_emails`.

```bash cURL theme={null}
curl -sS "$ORBIT_API_BASE/subaccounts/org_child_9f8a/notification-recipients" \
  -H "Authorization: Bearer $ORBIT_TOKEN"
```

```json 200 theme={null}
{
  "data": {
    "settings": {
      "notification_recipients": {
        "rate_sheet_emails": ["revops@acme.example"],
        "balance_alert_emails": ["oncall@acme.example", "finance@acme.example"]
      }
    }
  },
  "meta": { "request_id": "req_set005", "timestamp": "2026-10-13T09:15:00Z" }
}
```

`PUT` replaces the object. A non-empty list means "route these emails to exactly these addresses"; an empty or missing list means "inherit the previous hop's recipients", so you can override one category and leave the other inheriting:

```bash cURL theme={null}
curl -sS -X PUT "$ORBIT_API_BASE/subaccounts/org_child_9f8a/notification-recipients" \
  -H "Authorization: Bearer $ORBIT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "settings": {
      "notification_recipients": {
        "rate_sheet_emails": ["revops@acme.example"],
        "balance_alert_emails": []
      }
    }
  }'
```

The reader is deliberately tolerant: a stored value that is not the expected object (malformed JSONB from an old writer, a bare array, a string) collapses to `null` and the emitter falls back to inherited recipients instead of failing the send path. So a `GET` right after a legacy write can show `notification_recipients: null` — rewrite the full object to normalise it.

```typescript TypeScript theme={null}
await fetch(
  `${ORBIT_API_BASE}/subaccounts/org_child_9f8a/notification-recipients`,
  {
    method: "PUT",
    headers: {
      Authorization: `Bearer ${ORBIT_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      settings: {
        notification_recipients: {
          rate_sheet_emails: ["revops@acme.example"],
          balance_alert_emails: [], // empty = keep inheriting balance alerts
        },
      },
    }),
  },
);
```

```python Python theme={null}
requests.put(
    f"{base}/subaccounts/org_child_9f8a/notification-recipients",
    headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
    json={
        "settings": {
            "notification_recipients": {
                "rate_sheet_emails": ["revops@acme.example"],
                "balance_alert_emails": [],  # empty = keep inheriting balance alerts
            }
        }
    },
    timeout=30,
).raise_for_status()
```

## 5. Read and scope the channel-family allowlist

`GET /subaccounts/:id/allowed-channels` returns the channel families this sub-account may use. Absent any scoping, the child inherits every family the parent has enabled.

```bash cURL theme={null}
curl -sS "$ORBIT_API_BASE/subaccounts/org_child_9f8a/allowed-channels" \
  -H "Authorization: Bearer $ORBIT_TOKEN"
```

```json 200 theme={null}
{
  "data": { "allowed_channels": ["sms", "email", "whatsapp", "voice"] },
  "meta": { "request_id": "req_set006", "timestamp": "2026-10-13T09:20:00Z" }
}
```

Scope a child to SMS + Email only (for example, a brand that has not finished WhatsApp sender approval):

```bash cURL theme={null}
curl -sS -X PUT "$ORBIT_API_BASE/subaccounts/org_child_9f8a/allowed-channels" \
  -H "Authorization: Bearer $ORBIT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "allowed_channels": ["sms", "email"] }'
```

Restore inherit-all with `DELETE` (or a `PUT` with an empty list — both clear the override):

```bash cURL theme={null}
curl -sS -X DELETE "$ORBIT_API_BASE/subaccounts/org_child_9f8a/allowed-channels" \
  -H "Authorization: Bearer $ORBIT_TOKEN"
```

The allowlist composes with the lifecycle endpoints: it narrows which families a healthy child can use, while [suspend and reactivate](/guides/subaccount-lifecycle-suspend-reactivate) gate the whole account. A suspended sub-account sends on no channel regardless of its allowlist; reactivating restores exactly the allowlist that was set, so scoping survives a suspend/reactivate cycle and you do not need to re-apply it.

```typescript TypeScript theme={null}
await fetch(`${ORBIT_API_BASE}/subaccounts/org_child_9f8a/allowed-channels`, {
  method: "PUT",
  headers: {
    Authorization: `Bearer ${ORBIT_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ allowed_channels: ["sms", "email"] }),
});
// later: DELETE the same path (or PUT an empty list) to inherit all again
```

```python Python theme={null}
requests.put(
    f"{base}/subaccounts/org_child_9f8a/allowed-channels",
    headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
    json={"allowed_channels": ["sms", "email"]},
    timeout=30,
).raise_for_status()
# later: requests.delete(...) on the same path restores inherit-all
```

## 6. Chain-walk audit trail

When the panel badge says `source: platform` but ops expected an ancestor's sender to win, walk the tree yourself: start at the child, print each hop's configured state, and climb `parent_organization_id` until the chain resolves. The `resolved_from_organization_id` from [recipe 1](#1-read-the-effective-notification-email-config) is the fast answer; this walk is the audit trail that explains **why** — typically an ancestor's override is present but unverified, so the resolver skipped it.

```typescript TypeScript theme={null}
let orgId: string | null = "org_child_9f8a";
while (orgId) {
  const cfg = await fetch(
    `${ORBIT_API_BASE}/subaccounts/${orgId}/notification-email`,
    { headers: { Authorization: `Bearer ${ORBIT_TOKEN}` } },
  ).then((r) => r.json());
  console.log(
    orgId,
    "configured:", cfg.data.from_address,
    "verified:", cfg.data.verified,
  );
  if (cfg.data.effective.source === "subaccount") {
    console.log("resolves here:", orgId);
    break;
  }
  if (cfg.data.effective.source === "platform") {
    console.log("no verified override on the chain: platform default");
    break;
  }
  console.log("resolved at:", cfg.data.effective.resolved_from_organization_id);
  break;
}
// To inspect every hop rather than just the winner, climb the tree:
// read the sub-account (GET /subaccounts/:id) to obtain its
// parent_organization_id, repeat the GET at the parent, and stop when a
// sub-account has no parent — the next hop would be the platform default.
```

The common finding: an ancestor shows `configured.from_address` set but `verified: false`. The resolver only promotes verified senders, so the chain keeps climbing past it. Re-verify the ancestor's domain (the email-senders surface) or clear the stale override with [recipe 3](#3-clear-the-custom-sender-to-restore-the-parent-chain).

## 7. White-label reseller checklist

One sequence an API-first reseller runs when a child org goes live: create, set the commercial knobs, brand it, claim and verify the custom domain, install the sender identity, seed recipients, scope channels, and smoke-test with one send.

```bash cURL theme={null}
set -e
# 1. Create the child org (provisioning samples: /guides/subaccounts-reseller)
CHILD=$(curl -sS -X POST "$ORBIT_API_BASE/subaccounts" \
  -H "Authorization: Bearer $ORBIT_TOKEN" -H "Content-Type: application/json" \
  -d '{ "name": "Acme Sweden" }' | jq -r '.data.childOrgId')

# 2. Commercial knobs: margin + spend cap
curl -sS -X PUT "$ORBIT_API_BASE/subaccounts/$CHILD/pricing" \
  -H "Authorization: Bearer $ORBIT_TOKEN" -H "Content-Type: application/json" \
  -d '{ "reseller_margin_pct": 12, "monthly_spend_cap_cents": 500000 }'

# 3. Branding assets
curl -sS -X PUT "$ORBIT_API_BASE/subaccounts/$CHILD/branding" \
  -H "Authorization: Bearer $ORBIT_TOKEN" -H "Content-Type: application/json" \
  -d '{ "dashboard_name": "Acme Sweden", "logo_url": "https://cdn.acme.example/logo.svg" }'

# 4. Custom domain + verification
DOMAIN=$(curl -sS -X POST "$ORBIT_API_BASE/subaccounts/$CHILD/domains" \
  -H "Authorization: Bearer $ORBIT_TOKEN" -H "Content-Type: application/json" \
  -d '{ "domain": "portal.acme.example" }' | jq -r '.data.id')
# ...point the CNAME at .data.cname_target, then:
curl -sS -X POST "$ORBIT_API_BASE/subaccounts/$CHILD/domains/$DOMAIN/verify" \
  -H "Authorization: Bearer $ORBIT_TOKEN"

# 5. Custom notification sender (poll verified as in recipe 2)
curl -sS -X PUT "$ORBIT_API_BASE/subaccounts/$CHILD/notification-email" \
  -H "Authorization: Bearer $ORBIT_TOKEN" -H "Content-Type: application/json" \
  -d '{ "from_name": "Acme Sweden", "from_address": "noreply@acme.example" }'

# 6. Seed recipients
curl -sS -X PUT "$ORBIT_API_BASE/subaccounts/$CHILD/notification-recipients" \
  -H "Authorization: Bearer $ORBIT_TOKEN" -H "Content-Type: application/json" \
  -d '{ "settings": { "notification_recipients": {
        "rate_sheet_emails": ["revops@acme.example"],
        "balance_alert_emails": ["oncall@acme.example"] } } }'

# 7. Scope channels until WhatsApp approval lands
curl -sS -X PUT "$ORBIT_API_BASE/subaccounts/$CHILD/allowed-channels" \
  -H "Authorization: Bearer $ORBIT_TOKEN" -H "Content-Type: application/json" \
  -d '{ "allowed_channels": ["sms", "email"] }'

# 8. Smoke-test with one send from the child's key
curl -sS -X POST "$ORBIT_API_BASE/messages" \
  -H "X-API-Key: $CHILD_API_KEY" -H "Content-Type: application/json" \
  -d '{ "to": "+14155552671", "channel": "sms", "message": "Acme Sweden online." }'
```

Steps 5–7 are the settings surface this page covers; steps 1–4 link out to [reseller subaccounts](/guides/subaccounts-reseller) and the domain verification flow. Run step 8 with a child-scoped key so the send exercises the child's allowlist and budget, not the parent's.

## Troubleshooting

* **403 on every settings call.** The token's roles cover a child org, not the parent. Settings writes need owner or admin on the parent; a child admin can only target its own organization id.
* **404 on a id that exists.** The resource is hidden to this caller — the controller answers `404` instead of `403` for sub-accounts outside the token's visibility. Re-issue the token from the parent org.
* **422 with `VALIDATION_ERROR`.** The top entries: `from_address` not a valid email; `from_name`/`from_address` sent as wrong types (send `null` to clear, not `""`); an allowlist entry outside the known channel families; a recipients entry that is not an email address. Nothing persists on a `422`.
* **The silent fallback.** Your `PUT` returned `200` but `effective.source` is still `platform` or `ancestor`. The write succeeded — the activation gate did not pass. Check, in order: the new address's domain is not DKIM/SPF-verified yet (`verified: false`), you cleared with `""` instead of `null` on an earlier call, or you wrote a different `:id` than the one you are reading. Recipe 6 prints the whole chain and finds which hop is blocking.

## See also

* [Reseller subaccounts guide](/guides/subaccounts-reseller) — provisioning, credits, and the commercial lifecycle.
* [Sub-account lifecycle: suspend and reactivate](/guides/subaccount-lifecycle-suspend-reactivate) — how suspension composes with the allowlist.
* [Staging/production split](/guides/subaccount-staging-production-split) and [AI configuration](/guides/subaccount-ai-configuration) — the other per-subaccount settings surfaces.
* [API error handling by example](/guides/error-handling-examples) — the shared error envelope used above.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.