Skip to content
157 changes: 157 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,155 @@ 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-01T15:30:00Z \
--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 — SUBMITTED means validation passed and
# the order is with the vendor; INVALID_TFNS means one or more TFNs are not portable.

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> \
--customer-order-id agent-bulk-42 --if-not-exists --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`.
```

`bulk create` is two API calls under the hood: a template POST (`/bulkPortins`)
followed by a TN list PUT (`/bulkPortins/{id}/tnList`). If the second call
fails, the template order still exists as a TN-less `DRAFT` — unsubmitted
drafts expire after 2 days. Re-run the same command with
`--customer-order-id <id> --if-not-exists` to resume: the CLI finds the
stranded `DRAFT` and attaches the TN list to it instead of creating a new
template.

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

```bash
band portin supp <order-id> --foc 2026-07-01T15:30:00Z
# supp requires a full FOC timestamp (YYYY-MM-DDTHH:MM:SSZ); date-only values are rejected.
# `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. For `bulk create`, if the retried order is a TN-less `DRAFT` (a stranded template from a prior step-2 failure), the CLI attaches the TN list instead of just returning it.

**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
→ SUBMITTED (TFN validation passed)
→ INVALID_TFNS (terminal — fix TNs)
→ PENDING_DOCUMENTS / PENDING_CARRIER_APPROVAL → FOC → COMPLETE
(success path; FOC takes days)
→ EXCEPTION (terminal — read errorCode)
→ CANCELLED / REQUESTED_CANCEL (terminal — from explicit `cancel`)
```

`band portin submit --wait` blocks until the order leaves `VALIDATE_TFNS`: `SUBMITTED`, `INVALID_TFNS`, or a later state. 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 +651,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 +865,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
16 changes: 1 addition & 15 deletions cmd/number/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"fmt"
"net/url"
"strconv"
"strings"

"github.com/spf13/cobra"

Expand Down Expand Up @@ -127,7 +126,7 @@ func collectFullNumbers(v interface{}, out *[]string) {
switch x := v.(type) {
case map[string]interface{}:
if fn, ok := x["FullNumber"].(string); ok && fn != "" {
*out = append(*out, normalizeE164(fn))
*out = append(*out, cmdutil.NormalizeE164(fn))
return
}
for _, child := range x {
Expand All @@ -139,16 +138,3 @@ func collectFullNumbers(v interface{}, out *[]string) {
}
}
}

// normalizeE164 returns n in E.164 form. /tns may return either a 10-digit
// US number ("9195551234") or an already-prefixed value depending on whether
// the account is on the v2 E.164 response format.
func normalizeE164(n string) string {
if strings.HasPrefix(n, "+") {
return n
}
if len(n) == 10 {
return "+1" + n
}
return "+" + n
}
5 changes: 3 additions & 2 deletions cmd/number/number_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"testing"

"github.com/Bandwidth/cli/internal/api"
"github.com/Bandwidth/cli/internal/cmdutil"
)

func TestBuildOrderBody(t *testing.T) {
Expand Down Expand Up @@ -49,9 +50,9 @@ func TestNormalizeE164(t *testing.T) {
{"442071838750", "+442071838750"},
}
for _, c := range cases {
got := normalizeE164(c.in)
got := cmdutil.NormalizeE164(c.in)
if got != c.want {
t.Errorf("normalizeE164(%q) = %q, want %q", c.in, got, c.want)
t.Errorf("NormalizeE164(%q) = %q, want %q", c.in, got, c.want)
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/number/order.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ func runOrder(cmd *cobra.Command, args []string) error {
// otherwise --wait would return too early.
ordered := make(map[string]bool, len(args))
for _, n := range args {
ordered[normalizeE164(n)] = true
ordered[cmdutil.NormalizeE164(n)] = true
}

final, err := cmdutil.Poll(cmdutil.PollConfig{
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>`" + `.`,
}
Loading