Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,10 @@ When `--wait` times out (exit code 5), the operation may have succeeded — the
| `number activate --wait` / `number deactivate --wait` | Service activation order may still be RECEIVED/PROCESSING | Check `band number get <number> --plain` — the `inboundActivated` / `outbound*Activated` flags reflect the terminal state. Re-running the same activate is idempotent. |
| `call create --wait` | Call may still be active | Check `band call get <call-id> --plain` — look at the `state` field. |
| `transcription create --wait` | Transcription may be processing | Check `band transcription get <call-id> <rec-id> --plain`. |
| `portin validate-tf --wait` | TF validation order may still be PROCESSING | Check `band portin validate-tf <numbers> --plain` again — caching means a re-run is cheap. |
| `portin submit --wait` | Order may still be in VALIDATE_TFNS | Check `band portin get <order-id> --plain` — look at the `status` field. |
| `portin supp` | Supp's propagation poll timed out | Check `band portin get <order-id> --plain`. The CLI's silent-fail check (error code 7300) only runs against the GET it observed before timeout — re-run the GET before retrying the supp. |
| `portin bulk get-tns --wait` | TN list may still be VALIDATE_DRAFT_TNS | Re-run `band portin bulk get-tns <id> --plain`. |

**General rule:** after a timeout, query the resource state before retrying. Don't blindly re-run a create that might have succeeded.

Expand Down Expand Up @@ -467,6 +471,142 @@ band app peers <app-id> --plain # → locations linked to app (includes
band number list --plain # → all numbers on account
```

### Port a number into Bandwidth

Six end-to-end flows are completable via the public API. Anything outside this list (port-out, manual toll-free, internal toll-free, NASC overrides, international ports) requires Bandwidth ops or the Dashboard — `band portin` will not let you start those flows.

**1. Check toll-free portability before submitting an order:**

```bash
band portin validate-tf +18005551234 --wait --plain
# → [{"telephoneNumber":"+18005551234","portable":true,"respOrgId":"TST51","reason":""}]
# Exits 1 with the per-number reason if any number is non-portable.
```

**2. On-net domestic port-in (BWC000) end-to-end:**

```bash
band portin create \
--numbers +19195551234,+19195551235 \
--site <site-id> --peer <peer-id> \
--foc 2026-06-01Z \
--loa-authorizing-person "Jane Doe" \
--loa ./loa.pdf \
--customer-order-id agent-run-42 --if-not-exists --plain
# → {"orderId":"...","status":"DRAFT","numbers":["+19195551234","+19195551235"], ...}

ORDER_ID=$(... extract from above ...)
band portin submit $ORDER_ID --wait --plain
# Blocks until status leaves VALIDATE_TFNS — usually PENDING_DOCUMENTS or FOC_GRANTED.

band portin get $ORDER_ID --plain
# Re-poll later for FOC progression. Don't try to --wait for FOC; can take days.
```

**3. Toll-free Phase 1 port-in:** Same shape as on-net. The TF validation phase runs automatically. Requires `TOLL_FREE_AUTOMATION_PHASE_1` enabled on the account — without it, `create` exits 4 with a message naming the gate. Don't retry on exit 4; escalate to the Bandwidth account manager.

**4. Bulk port-in:**

```bash
band portin bulk create --numbers-file ./tns.txt --site <id> --peer <id> --plain
# → {"bulkOrderId":"...","status":"VALIDATE_DRAFT_TNS","childOrderIds":[], ...}

band portin bulk get-tns <bulk-order-id> --wait --plain
# Blocks until VALID_DRAFT_TNS or INVALID_DRAFT_TNS. childOrderIds populates with
# one ID per validated group — drive each through `band portin get`/`submit`.
```

**5. Modify an existing order (supp):**

```bash
band portin supp <order-id> --foc 2026-07-01Z
# `supp` always polls for propagation by default — it captures the order's
# pre-PUT lastModifiedDate, then waits until either that timestamp advances
# (real propagation) or error code 7300 appears (silent failure on the
# Bandwidth side, typically wireless_to_wireless after FOC). Exits 1 with
# a clear message on 7300; exits 5 on timeout. Do not assume success
# without running this command — a raw PUT can succeed without propagating.
```

**6. Lifecycle ops:**

```bash
band portin upload-loa <order-id> ./loa.pdf # post-creation document upload
band portin notes add <order-id> "Please expedite — customer outage"
band portin notes list <order-id> --plain
band portin history <order-id> --plain # state change audit
band portin cancel <order-id> # typically irreversible
```

**Idempotency.** `create` and `bulk create` accept `--customer-order-id <id> --if-not-exists`. On retry, an existing order with the same ID is returned with the same `--plain` shape — safe inside an agent reconciliation loop.

**Out of scope (will not work via API):**

| Flow | What happens if you try | Where to go instead |
|---|---|---|
| Port-out | No `band portout` exists; not a public API | Bandwidth Dashboard; ops |
| Toll-free Phase 2 / non-automated | `create` exits 4 with the Phase 1 gate message | Bandwidth ops |
| Toll-free internal port (BW account → BW account) | `create` will succeed creating a draft, but FOC requires manual provisioning | Bandwidth ops |
| International / non-NANP | Country-specific manual forms | Per-country ops process |
| NASC manual override | Email Somos Helpdesk | Internal ops process |

### Porting reference

**`--plain` shapes (v1, locked).** Field names will not change without a `--plain-version` migration.

| Command | Shape |
|---|---|
| `validate-tf` | `[{telephoneNumber, portable, respOrgId, reason}]` — array always, even for one TN |
| `create` / `get` / `submit` / `supp` | `{orderId, status, focDate, numbers, customerOrderId, errorCode}` |
| `list` | array of the create/get shape |
| `history` | `[{state, timestamp, actor}]` |
| `notes add` | `{orderId, noteId, location}` |
| `notes list` | `[{noteId, timestamp, actor, text}]` |
| `cancel` | `{orderId, status}` (always `status: "CANCELLED"`) |
| `upload-loa` | `{orderId, file, contentType, status}` (always `status: "UPLOADED"`) |
| `bulk create` / `bulk get` / `bulk get-tns` | `{bulkOrderId, status, childOrderIds, portableNumbers, nonPortable}` where `nonPortable: [{number, code, reason}]` |
| `bulk list` | array of the bulk create/get shape |

**Port-in state machine.** Poll `status` from `band portin get`:

```
DRAFT
→ VALIDATE_DRAFT_TFNS (TF validation running)
→ VALID_DRAFT_TFNS (ready for submit)
→ INVALID_DRAFT_TFNS (terminal — fix TNs, recreate)
→ (after `submit`)
→ SUBMITTED → VALIDATE_TFNS → PENDING_DOCUMENTS
→ FOC / FOC_GRANTED → COMPLETE (success path; FOC takes days)
→ REJECTED (terminal — read errorCode)
→ FAILED (terminal — system error)
→ CANCELLED (terminal — from explicit `cancel`)
```

`band portin submit --wait` blocks at the next stable state (`PENDING_DOCUMENTS` / `FOC` / terminal). It does **not** wait for `COMPLETE` — that requires the FOC date to arrive, which is days to weeks out.

**Reconciliation idiom.** Tag every create with a unique customer-order-id; retries are then idempotent:

```bash
COID="agent-run-$(uuidgen)"
band portin create --numbers +1... --site <id> --peer <id> --foc <date> \
--customer-order-id "$COID" --if-not-exists --plain
# On retry: returns the existing order's plain shape, same orderId. No duplicate.
```

**Common error codes encountered on porting endpoints:**

| Code | Where | Meaning | Fix |
|---|---|---|---|
| 1022 | any | TN format invalid | Pass numbers in full E.164 with country code (`+18005551234`, not `8005551234`) |
| 5217 | `notes add` | UserId required | Auto-handled by the CLI — should not surface unless config is corrupted |
| 7300 | `supp` (verifying GET) | Supp accepted by API but not propagated to Neustar | Order is in a state where supps are blocked (e.g., wireless_to_wireless past FOC). The CLI exits 1 — do not retry blindly; investigate the order state |
| 7615 | `validate-tf`, `create` (TF) | Invalid toll-free number | TN is malformed or out of TF range |
| 7626 | `validate-tf` | Toll-free vendor timeout (300s) | Transient — retry the validation |
| 7640 | `upload-loa` | documentType not specified | The CLI defaults to `documentType=LOA` — should not surface |
| 7642 | `validate-tf`, `bulk` | TF in spare status, not portable | Number must be acquired through ordering, not porting |
| 7643 | `validate-tf`, `bulk` | TF in unavailable status | Reserved by SOMOS — not portable |
| 7671 | `get`, `list` | Order was cancelled | Visible in `errorCode` on a cancelled order; not actionable |

## Exit Codes

| Code | Meaning | When |
Expand Down Expand Up @@ -498,6 +638,9 @@ band number list --plain # → all numbers on account
| "API error 429" | 7 | Rate limited or quota exceeded | Back off and retry — eventually retryable |
| "HTTP voice feature is required" | 4 | Legacy voice not available | Try VCP path (UP account) or contact support |
| "required flag not set" | 1 | Missing a required flag | Check `--help` for required flags |
| "toll-free port-ins via the API require Phase 1 automation" | 4 | Account doesn't have `TOLL_FREE_AUTOMATION_PHASE_1` enabled | Stop — escalate to the Bandwidth account manager. The number must be ported through the Dashboard or ops. |
| "supplement was accepted by the API but did not propagate to Neustar" | 1 | `band portin supp` detected error code 7300 on the verifying GET | The supp did NOT take effect. Typical cause: order is past FOC for wireless_to_wireless, or attempting a SUP-3 field change. Adjust strategy — don't blindly retry. |
| "one or more numbers are not portable" | 1 | `band portin validate-tf` returned `portable: false` for at least one TN | Inspect the per-number `reason` in the JSON; do not proceed to `create` for those numbers. |

### Messaging delivery errors

Expand Down Expand Up @@ -709,6 +852,7 @@ band message send --from +19195551234 --to +15559876543 --app-id abc-123 --text
- **No message content retrieval.** Bandwidth does not store message bodies. After sending, the message text is gone forever. `message get` and `message list` return timestamps, direction, and segment counts only.
- **10DLC: read + assign only.** The CLI can list campaigns, check number registration status, diagnose failures (`band tendlc`), and assign numbers to campaigns (`band tnoption assign`). It cannot create campaigns or register brands — those require the Bandwidth App. The CLI checks that a number is on a campaign and blocks sends if it's not.
- **TFV is check-and-submit.** The CLI can check toll-free verification status and submit new requests (`band tfv`), but cannot approve or expedite reviews — those happen on the carrier side.
- **Porting is port-IN only.** `band portin` covers the six end-to-end flows that complete via the public API: TF validation, on-net domestic, automated off-net (Level 3), TF Phase 1 (gated), bulk, and lifecycle ops (notes, supp, cancel, history, doc upload). Out of scope: port-out (no public API), manual TF, internal TF, NASC manual override, and international ports — these need ops or the Dashboard. `band portin create` exits 4 if the account doesn't have `TOLL_FREE_AUTOMATION_PHASE_1` for a TF order. `band portin supp` defends against the documented Bandwidth API behavior where a supp returns 200 on PUT but error code 7300 on the next GET (Neustar never received it) — exits 1 with a clear message rather than silently succeeding.
- **10DLC, TFV, and short code commands are role-gated.** A 403 can mean the credential lacks the required role (Campaign Management, TFV), the account doesn't have the Registration Center feature, or messaging isn't enabled. The CLI provides a diagnostic message — if it says "access denied," escalate to the Bandwidth account manager rather than retrying.
- **No batch operations.** Each command operates on one resource (except `vcp assign` which handles multiple numbers and `message send` which supports multiple recipients).
- **Dashboard API uses XML internally.** The CLI handles XML serialization transparently — you always send and receive JSON. Use `--plain` for predictable, flat output.
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,27 @@ Sub-accounts (formerly known as sites) are the top-level container. Locations (f
| `band tnoption get <id>` | Check the status of a TN Option Order |
| `band tnoption list` | List TN Option Orders (filter by `--status`, `--tn`) |

### Porting

`band portin` covers the porting flows that can complete entirely through the public API. Port-out, manual toll-free, internal toll-free, NASC overrides, and international ports require Bandwidth ops or the Dashboard.

| Command | What it does |
|---------|-------------|
| `band portin validate-tf <number...>` | Check whether toll-free numbers can be ported (with `--wait` to block until validation completes) |
| `band portin create --numbers <...>` | Create a draft port-in (optionally chains an `--loa` upload; supports `--customer-order-id` + `--if-not-exists` for idempotent retries) |
| `band portin get <order-id>` | Get the current state of a port-in order |
| `band portin list` | List port-in orders (filter by `--status`, `--from`, `--to`) |
| `band portin submit <order-id>` | Submit a draft port-in to Neustar / SOMOS (with `--wait`) |
| `band portin supp <order-id>` | Supplement an existing order; defends against the silent error 7300 trap |
| `band portin cancel <order-id>` | Cancel a port-in order |
| `band portin history <order-id>` | State-change history |
| `band portin upload-loa <order-id> <file>` | Attach an LOA / supporting document |
| `band portin notes add <order-id> <text>` | Add a note (used to communicate with Bandwidth's LNP team) |
| `band portin notes list <order-id>` | List notes |
| `band portin bulk create` | Submit a bulk port-in from a TN list |
| `band portin bulk get-tns <id>` | Poll the asynchronous TN-list validation |
| `band portin bulk get <id>` / `bulk list` | Inspect bulk orders |

### Other

| Command | What it does |
Expand Down
18 changes: 18 additions & 0 deletions cmd/portin/bulk/bulk.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Package bulk implements the `band portin bulk` command surface for managing
// bulk port-in orders. A bulk order accepts a large list of TNs, runs an
// asynchronous portability validation, and decomposes into one or more child
// port-in orders that can then be driven through the standard `band portin`
// lifecycle.
package bulk

import "github.com/spf13/cobra"

// Cmd is the `band portin bulk` parent command.
var Cmd = &cobra.Command{
Use: "bulk",
Short: "Manage bulk port-in orders",
Long: `Bulk port-ins accept a large TN list and split it into validated child
port-in orders. The TN list validation is asynchronous — submit with
` + "`bulk create`" + `, then poll completion with ` + "`bulk get-tns --wait`" + `.
Child orders are managed through the standard ` + "`band portin <subcommand>`" + `.`,
}
86 changes: 86 additions & 0 deletions cmd/portin/bulk/bulk_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package bulk

import (
"testing"
)

func TestCmdStructure(t *testing.T) {
if Cmd.Use != "bulk" {
t.Errorf("Use = %q, want %q", Cmd.Use, "bulk")
}
expected := []string{"create", "get", "get-tns", "list"}
have := map[string]bool{}
for _, c := range Cmd.Commands() {
have[c.Name()] = true
}
for _, want := range expected {
if !have[want] {
t.Errorf("missing subcommand %q", want)
}
}
}

// TestFlattenBulkResultLocksV1Shape verifies the bulk plain shape contract:
// {bulkOrderId, status, childOrderIds, portableNumbers, nonPortable}.
func TestFlattenBulkResultLocksV1Shape(t *testing.T) {
resp := map[string]interface{}{
"BulkPortinResponse": map[string]interface{}{
"OrderId": "bulk-abc",
"ProcessingStatus": "INVALID_DRAFT_TNS",
"PortableTnList": map[string]interface{}{
"TN": []interface{}{"8336531000"},
},
"ChildPortinOrderList": map[string]interface{}{
"ChildPortinOrder": map[string]interface{}{
"OrderId": "child-1",
"TnList": map[string]interface{}{
"Tn": "8336531000",
},
},
},
"ErrorList": map[string]interface{}{
"Error": map[string]interface{}{
"Code": "7642",
"Description": "TN list contains at least one toll free number that cannot be ported due to spare status.",
"TnList": map[string]interface{}{
"Tn": "8005587721",
},
},
},
},
}

got := flattenBulkResult(resp)
for _, k := range []string{"bulkOrderId", "status", "childOrderIds", "portableNumbers", "nonPortable"} {
if _, ok := got[k]; !ok {
t.Errorf("v1 plain shape missing key %q", k)
}
}
if got["bulkOrderId"] != "bulk-abc" {
t.Errorf("bulkOrderId = %v, want bulk-abc", got["bulkOrderId"])
}
children, _ := got["childOrderIds"].([]string)
if len(children) == 0 || children[0] != "child-1" {
t.Errorf("childOrderIds = %v, want [child-1]", children)
}
nonPortable, _ := got["nonPortable"].([]map[string]interface{})
if len(nonPortable) == 0 {
t.Fatal("expected at least one nonPortable entry")
}
if code, _ := nonPortable[0]["code"].(string); code != "7642" {
t.Errorf("nonPortable[0].code = %q, want 7642", code)
}
}

func TestStripE164(t *testing.T) {
cases := []struct{ in, want string }{
{"+18005551234", "8005551234"},
{"18005551234", "8005551234"},
{"8005551234", "8005551234"},
}
for _, tt := range cases {
if got := stripE164(tt.in); got != tt.want {
t.Errorf("stripE164(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
Loading
Loading