---
type: system
name: packetdial
display_name: "PacketDial (ACG VoIP / NetSapiens via OIT)"
last_compiled: 2026-06-22
compiled_by: GURU-5070/claude-main
sources:
  - .claude/memory/reference_packetdial_oit_netsapiens.md
  - .claude/skills/packetdial/SKILL.md
  - .claude/skills/packetdial/references/api.md
  - .claude/skills/yealink-ymcs/SKILL.md
  - session-logs/2026-06/2026-06-22-mike-packetdial-buildout-oitvoip-vault.md
  - session-logs/2026-06/2026-06-22-mike-packetdial-domain-onboarding-vwp.md
  - session-logs/2026-06/2026-06-22-mike-yealink-ymcs-skill.md
  - wiki/clients/valleywide.md
backlinks:
  - clients/valleywide
---

# PacketDial (ACG VoIP / NetSapiens via OIT)

> Two things live under this article: (1) the **PacketDial/NetSapiens PBX platform** — ACG's
> hosted VoIP offering, managed via the `packetdial` skill against the NetSapiens API v2; and
> (2) the **Yealink YMCS phone-management layer** — the cloud device manager that handles
> physical phone provisioning, firmware, and RPS zero-touch, managed via the `yealink-ymcs`
> skill. The two skills form the end-to-end onboarding pipeline for new VoIP clients.

---

## Vendor Stack

Do not conflate these four layers:

| Layer | What it is | Hostname / Identifier |
|---|---|---|
| **PacketDial** | ACG's own VoIP department / customer-facing brand | `pbx.packetdial.com` (API host); `voip.packetdial.com` (customer portal — no API) |
| **NetSapiens** | The underlying PBX/UCaaS software platform (SNAPsolution API v2, v44.4.10) | Same hosts as above |
| **OIT / OITVOIP** | White-label wholesale provider that runs NetSapiens and resells to MSPs; ACG's upstream | `api.ucaasnetwork.com` (OIT/NetSapiens white-label host — same platform as `pbx.packetdial.com`) |
| **YMCS (Yealink Management Cloud Service)** | Separate Yealink cloud for physical phone device management | `us-api.ymcs.yealink.com` (US region) |

`pbx.packetdial.com` and `api.ucaasnetwork.com` resolve to the same NetSapiens platform under
different white-label hostnames. The `packetdial` skill targets `pbx.` by default; if a future
endpoint 403s there, override with `PACKETDIAL_API_BASE_URL=https://api.ucaasnetwork.com/ns-api/v2`.

ACG's reseller territory on OIT's platform is `91912.service`.

---

## PBX Platform (NetSapiens via PacketDial / OIT)

### API

| Field | Value |
|---|---|
| Base URL | `https://pbx.packetdial.com/ns-api/v2` |
| Token endpoint | `https://pbx.packetdial.com/ns-api/v2/tokens` |
| Spec version | NetSapiens API v2 v44.4.10 — 239 paths / 354 operations (GET 139, POST 87, PUT 68, DELETE 50, PATCH 10) |
| OpenAPI spec | `https://pbx.packetdial.com/ns-api/webroot/openapi/openapi.json` |
| Swagger UI | `https://pbx.packetdial.com/ns-api/openapi` |
| Scope | Almost everything nested under `/domains/{domain}/...` |

**Auth — static API key (in use):** Send the `nsr_` key directly as `Authorization: Bearer <key>` —
no exchange step. Key prefix encodes scope: `nsr_` = reseller, `nss_` = system, `nsd_` = domain.
OAuth2 password grant (`POST /tokens` form-encoded) is also supported; the client caches and
refreshes JWTs ~60s before expiry.

**Reseller key (live-verified 2026-06-22):**

| Field | Value |
|---|---|
| Key-ID | `nsr_hSGUB5Wo` |
| Scope | Reseller `91912.service`; `user/domain: *` (sees every domain under ACG's territory) |
| Permissions | Read-write (`readonly: no`); cannot create sub-keys (`can-create-keys: no`) |
| Vault path | `msp-tools/oitvoip.sops.yaml` → `credentials.api_key` |

### Reseller Domains (as of 2026-06-22)

| Domain | Purpose | Notes |
|---|---|---|
| `0000.91912.service` | Reseller default / template | OIT platform default |
| `arizonacomputerguru` | ACG's own domain | **Sanctioned test bed — not in production use.** Use for exercising write wrappers safely. |
| `russo.91912.service` | Client domain | |
| `vwp.91912.service` | Valley Wide Plastering | Created 2026-06-22 via `onboard-domain`. E911 address id `a-6a395c03d4cfe` (301 N 56TH ST, Chandler AZ 85226, USPS VALID). Caller-ID 4807059500. |

### `packetdial` Skill

Skill location: `.claude/skills/packetdial/` (scripts: `ns.py`, `ns_client.py`).

**Reads (read-only by default, no gate):**

`domains`, `domain`, `users`, `user`, `phones`, `dids`, `devices`, `callqueues`, `timeframes`,
`sites`, `autoattendants`, `contacts`, `billing`, `addresses`, `smsnumbers`, `blocked-numbers`,
`moh`, `dialrules`, `recording`, `transcriptions`, `cdrs`, `resellers`

All listed reads are live-verified against the production reseller key (2026-06-22).

**Writes (every mutating command is `--confirm`-gated; prints `[DRY RUN]` and exits non-zero
without the flag):**

| Resource | Wrappers | Verification |
|---|---|---|
| Domains | `create-domain`, `onboard-domain` | `onboard-domain` end-to-end verified (vwp.91912.service) |
| Users | `create-user`, `update-user`, `delete-user` | [P] |
| Call queues | `create/update/delete-callqueue`, `add/update/remove-agent` | [V] full lifecycle on `arizonacomputerguru` |
| Time frames | `create/update/delete-timeframe` | [V] full lifecycle on `arizonacomputerguru` |
| Contacts | `create/update/delete-contact` | [V] on `arizonacomputerguru` |
| DIDs | `create-did`, `update-did`, `delete-did` | [P] |
| SIP devices | `create-phone`, `create/update/delete-device` | [P] |
| E911 addresses | `create/update/delete-address` | [P] (create requires `addresses/validate` → pidflo first) |
| Sites | `create-site`, `update-site` | [P] |
| Auto-attendants | `create-autoattendant` | [P] |
| SMS numbers | `create/update/delete-smsnumber` | [P] (requires SMS-provisioned domain) |
| Blocked numbers | `block-numbers`, `unblock-numbers` | [P] (202'd but didn't persist on empty test domain) |
| Music on hold | `create-moh` (TTS), `delete-moh` | [P] (multipart file upload → use `raw`) |

**[V]** = lifecycle-verified on ACG test domain; **[P]** = plumbed per spec, not lifecycle-verified.

**Raw escape hatch:** `ns.py raw <METHOD> <path>` reaches any of the 239 v2 paths. Non-GET methods
still require `--confirm`.

### `onboard-domain` Wrapper

One gated command that runs the OITVOIP "Add a Domain" GUI wizard as 3 API calls:

1. `POST /domains` — creates the domain (Basic + Defaults + Limitations fields).
2. `POST /domains/{domain}/addresses/validate` — validates the E911 address, returns `address-formatted-pidflo` + `emergency-address-id`.
3. `POST /domains/{domain}/addresses` — creates the E911 address with the pidflo from step 2.

Usage: `ns.py onboard-domain --body-file new-client.json --confirm`

The body is the `POST /domains` schema plus an optional `emergency` sub-object carrying the E911
address fields. Omit `limits-max-*` for unlimited seat counts (they default to 0 = uncapped; do not
set them to a sentinel). Omit `dial-plan` (auto-generates named after the domain).

Undo a bad onboard: `ns.py raw DELETE domains/<domain> --confirm`.

### PBX Gotchas

- **`domain-type` stores as `"no"` on create** — the field sent as `"Standard"` came back `"no"` on
  `vwp.91912.service`. Fix post-create: `ns.py raw PUT domains/<domain> --body '{"domain-type":"Standard"}' --confirm`.
- **Voicemail user-defaults not in `POST /domains`** — the GUI Defaults tab (Enable VM, Transcription,
  Message) has no equivalent in the domain-create schema; how the GUI applies them is unresolved (verify).
- **`email-send-from-address` left blank on create** — must be set separately once the sender mailbox
  exists: `ns.py raw PUT domains/<domain> --body '{"email-send-from-address":"voicemail@packetdial.com"}' --confirm`.
  ACG's own domain uses `notify@oitvoip.com` as the working pattern.
- **Unlimited seat counts = omit `limits-max-*`** — do not pass 0 or a sentinel; omitting the fields
  lets the platform default to uncapped.
- **Timeframe writes are body-discriminated** — the same path handles all operations; the server
  selects create/update/delete based on the request body. Entry-bearing types (days-of-week, etc.)
  reject creation without their array — create as `always` type first, then convert.
- **`voip.packetdial.com` has no API** — this is the customer-facing white-label portal (Cascades fax
  account 28598 lives here). Hitting `/ns-api/*` on this host returns `errors/not_found`.
- **CDR queries are unbounded by default** — always pass `--start`/`--end` and `--limit`.
- **This is the live production PBX.** Confirm the target domain with a read command before any write.
  A bad `create-domain` or `delete-user` affects real customers.

---

## Phone Management (Yealink YMCS)

### API

| Field | Value |
|---|---|
| Base URL | `https://us-api.ymcs.yealink.com` (US region; EU = `eu-api.ymcs.yealink.com`; AU = `au-api.ymcs.yealink.com`) |
| Region env | `YMCS_REGION` (us | eu | au; default us) |
| Auth | `POST /v2/token` with HTTP Basic `base64(AccessKeyID:Secret)` + headers `timestamp` (ms) + `nonce` + body `{"grant_type":"client_credentials"}` → bearer token (~24h) |
| TLS | **Requires legacy TLS renegotiation** — handled in-client via `OP_LEGACY_SERVER_CONNECT` SSL context |
| Vault path | `services/yealink-ymcs.sops.yaml` → `credentials.access_key_id` / `credentials.access_key_secret` |

### Account Tree (verified 2026-06-22)

The ACG AccessKey is the **parent account** — one key covers all client sites as children. No
per-client API keys are needed.

```
Arizona Computer Guru LLC  (parent / ACG account)
├── VWP             (Valley Wide Plastering)
├── GuruHQ          (ACG office)
└── Ace Pick Up Parks
```

### RPS Provisioning Server

| Field | Value |
|---|---|
| Server name | `WL - ACG` |
| URL | `ftp://p.packetdials.net` |
| Purpose | Zero-touch provisioning — phones pull their config from this RPS server on first boot |

### `yealink-ymcs` Skill

Skill location: `.claude/skills/yealink-ymcs/` (scripts: `ymcs.py`, `ymcs_client.py`).

**Reads (live-verified unless noted):**

`sites`, `devices` (21 total; `--site` filter best-effort [P]), `accounts`, `rps-servers`,
`rps-devices`, `device-groups`, `device-configs`, `firmwares`, `models`, `alarms`, `oplogs`

`official-firmwares` returns a non-standard shape (likely requires a model param) — marked [P].

List endpoints auto-paginate (`{skip,limit,autoCount}` → `{total,data:[]}`); wrappers return
`{total, count, data}` across all pages.

**Writes (all `--confirm`-gated; not lifecycle-verified — no throwaway device to safely test against):**

| Command | Effect |
|---|---|
| `add-devices-by-mac` | Bulk-add phones by MAC address to a site |
| `add-sipaccount` | Push a SIP credential onto a device (the PBX↔phone integration glue) |
| `reboot` | Reboot device(s) by ID |
| `reset` | Factory-reset device(s) — destructive |
| `rps-add` | Register device(s) in RPS for zero-touch provisioning |
| `rps-del` | Remove device(s) from RPS |

**Raw passthrough:** `ymcs.py raw POST /v2/dm/listDevices --body '{...}'` reaches any `/v2/dm/*` or
`/v2/rps/*` endpoint.

### YMCS Gotchas

- **Legacy TLS required.** If a future Python update drops `OP_LEGACY_SERVER_CONNECT`, connections to
  YMCS will silently fail. The SSL context with that flag is baked into `ymcs_client.py`.
- **MSYS path conversion on `raw`.** A leading `/v2/...` argument gets rewritten by Git-Bash to
  `C:/Program Files/Git/v2/...`. The `raw` command auto-recovers (strips the prefix back to `/v2/`).
  Do NOT set `MSYS_NO_PATHCONV=1` — it breaks the vault subprocess and causes credential failures.
  Named wrappers (hardcoded paths) are unaffected.
- **`/v2/dm/sipAccounts` is a CREATE endpoint, not a list.** It is wrapped as the gated
  `add-sipaccount` write. There is no list-SIP-accounts endpoint in the v2 API.
- **Per-client portal logins exist** (e.g., vault `clients/valleywide/`) even though the API key
  covers all clients. The portal admin for the ACG parent account is `admin@azcomputerguru.com`.

---

## Onboarding Pipeline

New VoIP client flow (the two skills together):

```
1. packetdial onboard-domain  →  PBX domain created + E911 address validated and registered.
2. packetdial create-user     →  SIP extensions created per user.
3. packetdial create-did      →  DIDs attached and routed to users.
4. yealink-ymcs add-devices-by-mac  →  Physical phones added to the client's YMCS site.
5. yealink-ymcs add-sipaccount      →  NetSapiens SIP credential pushed onto each phone.
6. yealink-ymcs rps-add             →  (Optional) Phone registered in RPS WL-ACG for zero-touch
                                        on first boot (pulls config from ftp://p.packetdials.net).
```

Result: new client → domain live → phones registered to `pbx.packetdial.com`.

**VWP is the live pilot** (2026-06-22): `vwp.91912.service` on the PBX, 21 devices in YMCS
(fleet partly pending — 16x Yealink T54W plus others). Steps 4–6 not yet exercised on
production VWP hardware (writes are [P] for YMCS).

**Post-onboard manual steps (gaps vs. full GUI wizard):**

- Fix `domain-type` if it stored as `"no"` (see PBX gotchas above).
- Set `email-send-from-address` once the `voicemail@packetdial.com` / `noreply@packetdial.com`
  mailbox exists (pending infra task as of 2026-06-22).
- Apply voicemail user-defaults — mechanism for applying the GUI Defaults tab (Enable VM,
  Transcription, Message) via the API is unresolved (verify).

---

## Access

| Credential | Vault Path | Field(s) |
|---|---|---|
| NetSapiens reseller API key | `msp-tools/oitvoip.sops.yaml` | `credentials.api_key` |
| YMCS API AccessKey | `services/yealink-ymcs.sops.yaml` | `credentials.access_key_id`, `credentials.access_key_secret` |
| Yealink portal admin (`admin@azcomputerguru.com`) | `infrastructure/voip-phones.sops.yaml` | (verify field names) |
| VWP per-client YMCS portal | `clients/valleywide/` | (verify entry name) |

Read a field: `bash .claude/scripts/vault.sh get-field <vault-path> <field>`

**NEVER inline raw secrets or API key values** — reference vault paths only.

---

## Known Issues & Quirks

- **`domain-type` stored as `"no"` on domain create (observed 2026-06-22).** Re-PUT the field after
  `onboard-domain`. `onboard-domain` wrapper does NOT auto-correct this — it is a platform behavior.
- **Voicemail user-defaults have no `POST /domains` field.** The GUI Defaults tab (Enable Voicemail,
  Transcription, Message to Email) is not represented in the domain-create schema. How and where the
  GUI applies these defaults is unresolved; set manually post-onboard (verify the mechanism).
- **`email-send-from-address` must be set separately.** Required for voicemail-to-email delivery. The
  sender mailboxes (`voicemail@packetdial.com` / `noreply@packetdial.com`) were not yet created as of
  2026-06-22. ACG's own domain uses `notify@oitvoip.com` as the working pattern.
- **Blocked-number filters inconclusive on empty domains.** `block-numbers` returned 202 but the
  number did not persist on the `arizonacomputerguru` test domain (empty). Verify on a live domain
  with real traffic before relying on this feature.
- **YMCS write wrappers are [P] (plumbed, not lifecycle-verified).** No throwaway device was
  available to safely test `add-devices-by-mac`, `add-sipaccount`, `reboot`, `reset`, or `rps-add/del`.
  Exercise first on a non-critical device (e.g., a phone already due for reprovisioning).
- **Yealink known-bad firmware `96.86.0.20`** is a documented T54W brick-maker. Before any mass
  firmware push via YMCS, confirm the active firmware policy is NOT targeting this version.
  See `clients/valleywide/docs/yealink-t54w-recovery-procedure.md` for the TFTP recovery procedure.
- **OIT-API.txt plaintext** was present at `C:\Users\guru\Downloads\OIT-API.txt` as of 2026-06-22.
  Mike to delete; key is in the vault.

---

## Backlinks

- [[clients/valleywide]] — VWP is the live pilot client; 21 Yealink devices in YMCS,
  `vwp.91912.service` on the PBX, Syncro ticket #32375 (New Phone Install) open.
- Skills: `.claude/skills/packetdial/` (`packetdial`) and `.claude/skills/yealink-ymcs/`
  (`yealink-ymcs`).
