# Numbers and routing

A trunk needs phone numbers. Numbers let people call your PBX, and they are the caller IDs your PBX
may show when it calls out. This page explains how you find and buy numbers on Signals, how you
point them at a trunk, and how you hand slices of them to subtrunks.

## How numbers are organised

Signals sells Nigerian numbers in **blocks**: runs of consecutive numbers of size 10, 50, 100, 500
or 1,000. A block of 100 might run from `+2342013300100` to `+2342013300199`. Block IDs start with
`nbk_`.

Buying a block takes four steps, each with its own word:

| Step | Word | What it is |
| --- | --- | --- |
| 1 | Search | Find available blocks that match a prefix, size or memorable pattern. |
| 2 | Hold | Reserve one exact block for 15 minutes so nobody else can take it while you decide. |
| 3 | Quote | The fixed price Signals gives you with the hold. It does not change while it is valid. |
| 4 | Order | Accept the quote. Signals charges your balance and makes the block yours. |

After you own a block, you **route** it: you tell Signals which trunk each number, or each slice of
numbers, should ring.

Every number belongs to a **tier**, a price class such as `standard`. Memorable numbers sit in
higher tiers. `GET /v1/numbers/tiers` lists the tiers in rank order, with their vanity patterns and
your project's setup and monthly prices. A tier with `null` prices is not sold to your project.

Prices are integers of kobo in strings, so `"1500000"` is ₦15,000.00. The prices in the examples
below are illustrative, and some responses are trimmed to the fields this page discusses.

## Search for a block

`GET /v1/numbers/blocks/search` returns the blocks you can hold now under `data`:

```bash
curl "$SIGNALS/numbers/blocks/search?size=100&prefix=201330" \
  -H "Authorization: Bearer $SIGNALS_API_KEY"
```

| Parameter | Meaning |
| --- | --- |
| `size` | Required. `10`, `50`, `100`, `500` or `1000`. |
| `prefix` | The national digits after `+234`, up to 10 digits. Spaces are allowed. `201330` finds numbers starting `+234201330`. |
| `patterns` | Comma-separated pattern codes from `GET /v1/numbers/tiers`. |
| `digits` | Digits a memorable number should contain, up to 10. |
| `match` | How `digits` match: `ends_with` (the default) or `contains`. |
| `setup_up_to_minor` | The most you want to pay in setup, in kobo. |

```json
{
  "data": [
    {
      "vanity_number": null,
      "start": "+2342013300100",
      "end": "+2342013300199",
      "size": 100,
      "tier": "standard",
      "reasons": [],
      "as_of": "2026-10-01T11:04:52Z",
      "adjacent_to": null,
      "setup_minor": 1500000,
      "monthly_minor": 2000000,
      "currency": "NGN",
      "tiers": [
        {
          "tier": "standard",
          "count": 100,
          "setup_unit_minor": 15000,
          "monthly_unit_minor": 20000,
          "initial_unit_minor": 20000
        }
      ]
    }
  ]
}
```

When you search with `digits`, `vanity_number` shows the memorable number separately from the block
that contains it. `adjacent_to` names a block you already own that this candidate sits next to.

## Hold a block

Hold the exact candidate by sending its `start`, `end` and `size`. Add `vanity_number` if you
searched for one:

```bash
curl -X POST "$SIGNALS/numbers/blocks/holds" \
  -H "Authorization: Bearer $SIGNALS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 3a8e1f62-5b0c-4d97-a2e4-8f6c1b9d0e35" \
  -d '{ "start": "+2342013300100", "end": "+2342013300199", "size": 100 }'
```

```json
{
  "id": "nbk_01992f40-8e52-7c19-a3d4-6f0b2e9c1d77",
  "block_id": "nbk_01992f40-8e52-7c19-a3d4-6f0b2e9c1d77",
  "start": "+2342013300100",
  "end": "+2342013300199",
  "size": 100,
  "origin": "standard",
  "vanity": null,
  "held_at": "2026-10-01T11:05:30Z",
  "expires_at": "2026-10-01T11:20:30Z",
  "quote": {
    "id": "quo_01992f40-8e53-7b2a-91c5-0d4e7a3f6b12",
    "version": 1,
    "currency": "NGN",
    "setup_minor": 1500000,
    "monthly_minor": 2000000,
    "initial_minor": 1000000,
    "lines": [
      { "label": "Setup, 100 standard numbers", "amount_minor": 1500000 },
      { "label": "Rental to the end of October", "amount_minor": 1000000 }
    ],
    "expires_at": "2026-10-01T11:20:30Z"
  },
  "target": null
}
```

- The hold lasts 15 minutes. `expires_at` is the server's deadline; after it, the block returns to
  stock.
- `initial_minor` is the first month's rental, prorated from today to the end of the month.
- Your organization can hold at most 25 blocks and 25,000 numbers at once by default. Going over
  returns `409 number_hold_limit_exceeded`.
- If someone else took the block first, you get `409 number_candidate_unavailable`. Search again.

To let a hold go before it expires, call `DELETE /v1/numbers/blocks/{block_id}/hold` with
`Idempotency-Key`. `GET /v1/numbers/holds` lists your open holds.

## Read the quote

The order step needs the quote's hash, which the hold response does not include. Read the quote:

```bash
curl "$SIGNALS/numbers/quotes/quo_01992f40-8e53-7b2a-91c5-0d4e7a3f6b12" \
  -H "Authorization: Bearer $SIGNALS_API_KEY"
```

```json
{
  "id": "quo_01992f40-8e53-7b2a-91c5-0d4e7a3f6b12",
  "block_id": "nbk_01992f40-8e52-7c19-a3d4-6f0b2e9c1d77",
  "version": 1,
  "status": "active",
  "quote_hash": "c5e1a9f3d7b20864e3f1a5c9d7b2e06f4a8c1e5d9b3f7a2c6e0d4b8f1a5c9e37",
  "setup_minor": 1500000,
  "initial_minor": 1000000,
  "expires_at": "2026-10-01T11:20:30Z",
  "accepted_at": null,
  "order": null
}
```

`status` is `active` while you can accept it, then `expired` or `accepted`.

## Buy the block

Accept the quote:

```bash
curl -X POST "$SIGNALS/numbers/blocks/nbk_01992f40-8e52-7c19-a3d4-6f0b2e9c1d77/assign" \
  -H "Authorization: Bearer $SIGNALS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 6d2b9f47-0e1a-4c83-b5f6-9a3e7c1d4b20" \
  -d '{
    "quote_id": "quo_01992f40-8e53-7b2a-91c5-0d4e7a3f6b12",
    "quote_hash": "c5e1a9f3d7b20864e3f1a5c9d7b2e06f4a8c1e5d9b3f7a2c6e0d4b8f1a5c9e37"
  }'
```

```json
{
  "block_id": "nbk_01992f40-8e52-7c19-a3d4-6f0b2e9c1d77",
  "state": "pending_charge",
  "total_minor": 2500000,
  "currency": "NGN"
}
```

`total_minor` is the setup fee plus the prorated first month. Signals charges your balance in the
background. Read the block with `GET /v1/numbers/blocks/{block_id}` until it is `active`. If your balance is
too low, the order ends in `insufficient_funds`. An expired quote returns `409 number_quote_expired`.

## Route numbers to a trunk

A route connects a slice of a block to a target. Calls to numbers in the slice ring that target.
Each block has a `revision` of its own, on `GET /v1/numbers/blocks/{block_id}` and in the block's
`ETag`. Send it, in quotes, as `If-Match`:

```bash
curl -X PUT "$SIGNALS/numbers/blocks/nbk_01992f40-8e52-7c19-a3d4-6f0b2e9c1d77/routes" \
  -H "Authorization: Bearer $SIGNALS_API_KEY" \
  -H "Content-Type: application/json" \
  -H 'If-Match: "1"' \
  -H "Idempotency-Key: 4c7e2a91-8d3f-4b56-a0e9-1f5b8d2c7a64" \
  -d '{
    "slices": [
      {
        "start": "+2342013300100",
        "end": "+2342013300199",
        "target": { "type": "trunk", "id": "trk_01992f3a-6c1e-7d42-9b8a-5e7f0c2d1a94" }
      }
    ]
  }'
```

The API returns `200 OK` with the block's routes as saved:

```json
{
  "data": [
    {
      "start": "+2342013300100",
      "end": "+2342013300199",
      "count": 100,
      "desired": { "type": "trunk", "id": "trk_01992f3a-6c1e-7d42-9b8a-5e7f0c2d1a94", "name": "office-pbx" },
      "effective": null,
      "status": "pending",
      "failure": null
    }
  ]
}
```

The route takes effect within a minute. Then `effective` names the trunk and `status` is `applied`.

- Slices in one request must not overlap. Slices you leave out keep their current route.
- A slice target can be a `trunk`, `subtrunk` or `voice_application`.
- A `null` target parks the numbers: they stay yours but ring nothing.
- `GET /v1/numbers/blocks/{block_id}/routes` shows each slice with the target you saved
  (`desired`), the target calls reach now (`effective`) and a `status` of `applied`, `pending` or
  `failed`.

The target trunk must be ready to take calls. If it is not, the request fails with
`409 conflict_target_version` and a `reason`:

| Reason | What to fix |
| --- | --- |
| `target_not_found` | The trunk does not exist in this project. |
| `target_disabled` | The trunk is turned off. |
| `target_not_inbound` | The trunk's `direction` is `outbound`. Use `inbound` or `both`. |
| `target_not_admitting` | The network is refusing traffic on the trunk, for example while it is suspended. |

### Routed numbers become caller IDs

A trunk may present only the numbers routed to it as caller ID on outbound calls. Until a number is
routed, the trunk has no caller ID it may present. A number can be used as caller ID as soon as its
route applies. The trunk's `numbers` field shows how many numbers are routed to it.

## Delegate slices to subtrunks

A subtrunk is a child of a trunk. It shares the parent's connection and authentication but has its
own name, status and limits, and the limits can only be tighter than the parent's. Use subtrunks to
separate departments or customers on one PBX connection, each with its own numbers and caps.
Subtrunk IDs start with `str_`.

### Create a subtrunk

```bash
curl -X POST "$SIGNALS/trunks/$TRUNK_ID/subtrunks" \
  -H "Authorization: Bearer $SIGNALS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 9b4f1d36-2c8e-4a70-b3d5-7e0a6c9f2b18" \
  -d '{
    "name": "sales-line",
    "status": "enabled",
    "limits": {
      "concurrency": 10,
      "cps": 2,
      "max_call_duration_seconds": 3600,
      "max_destination_rate_minor": null,
      "daily_spend_cap_minor": 1000000,
      "velocity_cap_minor": null
    }
  }'
```

A `null` limit inherits the parent's value. A value above the parent's is refused. When you send
`limits`, on a create or an update, send all six keys: a body that leaves one out is refused with
`422 invalid_subtrunk` rather than clearing that limit. The subtrunk is live once created and takes
effect within a minute.

### Delegate a slice

A delegation hands a slice of a block, already routed to the parent trunk, to one of its subtrunks:

```bash
curl -X POST "$SIGNALS/numbers/blocks/nbk_01992f40-8e52-7c19-a3d4-6f0b2e9c1d77/delegations" \
  -H "Authorization: Bearer $SIGNALS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 2e6a0c84-7f1b-4d39-9c52-a8d3f5b1e076" \
  -d '{
    "subtrunk_id": "str_01992f45-3b07-7f2c-8a16-4d9e1c5b7f30",
    "start": "+2342013300100",
    "end": "+2342013300199",
    "size": 100
  }'
```

The API returns `200 OK` with the block as saved, the new delegation listed under `delegations`. It
takes effect within a minute. A delegation must meet all of these rules:

- `size` is `100`, `500` or `1000`, and `start` to `end` covers exactly that many numbers.
- The slice is aligned: its distance from the start of the block is a multiple of `size`.
- The block is active, and the slice is inside it.
- The parent trunk already has an applied route covering the slice.
- The subtrunk and its parent trunk are both enabled.
- The slice does not overlap another live delegation.

A rule that fails returns `409 number_delegation_conflict` or `422`. To undo a delegation, call
`DELETE /v1/numbers/blocks/{block_id}/delegations/{delegation_id}` with `Idempotency-Key`. It also
returns `200 OK` with the block.

## Release a block

`POST /v1/numbers/blocks/{block_id}/release` with the exact `start` and `end` of the block starts
returning it and answers `200 OK` with the block. The API refuses a block that still has active routes or delegations
(`409 number_block_in_use`). The block moves to `pending_release`, then to `quarantined` once the
network confirms. `quarantine_until` on the block shows when the quarantine ends.

## Related

- [Outbound policies](/sip-trunks/outbound-policies) decide where the trunk may call.
- [Numbers API reference](/api/numbers)
