# Requests and errors

Every Signals API endpoint follows the same conventions for requests, retries, concurrent edits, background work and errors. This page explains each one. The Twilio-compatible Calls API follows Twilio's conventions instead; see [Moving from Twilio](/programmable-voice/twilio-compatibility).

## Base URL

Send every Signals API request to:

```text
https://api.signals.pressone.africa/v1
```

Paths in the [API reference](/api) are relative to this base URL. For example, `GET /trunks` means `GET https://api.signals.pressone.africa/v1/trunks`. Every request needs an API key; see [Authentication](/getting-started/authentication).

## Request and response bodies

Requests and responses use JSON. Send `Content-Type: application/json` with every request that has a body.

Request bodies are strict. A body that is not valid JSON, misses a required field or contains a field the endpoint does not define is refused, usually with `422`. Fix the body before you retry.

Headers you may send:

| Header | When | Purpose |
| --- | --- | --- |
| `Authorization` | Every request | `Bearer` followed by your API key. |
| `Content-Type` | Requests with a body | `application/json`. |
| `Idempotency-Key` | Creates and actions, as the reference marks | Makes a retry safe. See [Idempotency](#idempotency). |
| `If-Match` | Changes to versioned resources, as the reference marks | Refuses the change if the resource changed since you read it. See [ETag and If-Match](#etag-and-if-match). |
| `X-Project-ID` | Optional | The project the request acts on. See [Choosing a project](/getting-started/authentication#choosing-a-project). |
| `X-Request-ID` | Optional | Your own ID for the request. See [Request IDs](#request-ids). |

## Request IDs

Every response carries an `X-Request-ID` header. If you send `X-Request-ID` yourself, with up to 128 printable ASCII characters and no spaces, Signals uses your value. Otherwise it generates one, such as `req_0192b7e93c4f7a2e9d1b6c8f0a5e3d27`.

Log the request ID with every failure. Error bodies repeat it in `request_id`, and PressOne support uses it to find the request.

## Timestamps

Send timestamps in RFC 3339 format with an explicit offset. Both of these name the same instant:

```text
2026-10-01T10:30:00+01:00
2026-10-01T09:30:00Z
```

A timestamp without an offset, such as `2026-10-01T10:30:00`, is refused, because Signals cannot know which time zone you meant.

Signals returns every timestamp in UTC with the `Z` suffix, such as `2026-10-01T09:30:00Z`. Some timestamps carry fractional seconds. Convert to Africa/Lagos time, or any other zone, when you display them.


## Money

Signals never sends money as a decimal number. Money fields end in `_minor` and hold a whole number of minor units: kobo for naira, where 100 kobo is ₦1. A `currency` field alongside names the currency.

```json
{
  "daily_spend_cap_minor": 500000,
  "currency": "NGN"
}
```

This cap is 500,000 kobo, which is ₦5,000.00.

Every money field is a JSON integer, such as `500000`, with a leading `-` for a negative amount. Requests send money the same way; a string such as `"500000"` is refused with `422`. An amount can be larger than 2^53, which a JavaScript `JSON.parse` or any floating-point type rounds, so read money into a 64-bit integer or an arbitrary-precision type and no kobo is lost.

Durations follow the same idea, with the unit at the end of the name and always a JSON integer. Fields ending in `_ns` are whole nanoseconds, which can also pass 2^53: `"observed_duration_ns": 18000000000` is 18 seconds. Durations ending in `_ms` are whole milliseconds and those ending in `_seconds` whole seconds. Quality measurements such as `mos` and `jitter_ms` are JSON numbers, and percentages ending in `_pct` are decimal strings such as `"62.4"`.

## IDs

Every Signals resource ID starts with a prefix that names its type, followed by a UUID:

```text
trk_0192b7e1-4c2a-7f58-b913-2d6e0a9c5f41
```

| Prefix | Resource | Prefix | Resource |
| --- | --- | --- | --- |
| `trk_` | Trunk | `str_` | Subtrunk |
| `pol_` | Outbound policy | `pat_` | Policy attachment |
| `nbk_` | Number block | `nun_` | Individual number |
| `quo_` | Number quote | `sub_` | Subaccount |
| `cal_` | Call | `rec_` | Recording |
| `vap_` | Voice application | `va_` | Voice account |
| `whe_` | Webhook endpoint | `whd_` | Webhook delivery |
| `rlp_` | Webhook replay | `evt_` | Event |
| `alr_` | Alert | `alp_` | Alert policy |
| `stm_` | Statement | `inv_` | Invoice |
| `use_` | Usage record | `key_` | API key |
| `tst_` | Trunk test | `crq_` | Credit request |
| `aud_` | Audit event | | |

A path that expects one type refuses an ID with another prefix. Project IDs are plain UUIDs without a prefix. Programmable Voice keeps Twilio-style identifiers: `AC` for the voice account, `CA` for calls and `RE` for recordings.

Treat IDs as opaque strings. Compare them exactly and do not parse them.

## Idempotency

A network failure can leave you unsure whether a request reached Signals. Idempotency lets you send the request again without doing the work twice.

Send an `Idempotency-Key` header with every create and action request. The [API reference](/api) marks the requests that require it, including creating trunks, policies, subaccounts and webhook endpoints, suspending and resuming trunks, and buying and releasing numbers. Use a fresh random value, such as a UUID, for each new piece of work:

```bash
curl -X POST https://api.signals.pressone.africa/v1/subaccounts \
  -H "Authorization: Bearer $SIGNALS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 6f1d2c9a-8b3e-4f70-a5d1-2e9c7b0f4a63" \
  -d '{
    "name": "branch-office",
    "configuration": {
      "max_concurrent": 10,
      "max_cps": 2,
      "max_call_duration_seconds": 3600,
      "daily_spend_cap_minor": 500000,
      "max_destination_rate_minor": null,
      "currency": "NGN"
    }
  }'
```

The key must be one non-blank value of at most 128 characters. A missing, blank, repeated or longer key fails with `400` and code `invalid_idempotency_key`.

Signals remembers each key for 24 hours, scoped to your organization, the credential that sent it, the method and the path. Within that window:

| You send | Signals does |
| --- | --- |
| The same key with the same body | Returns the result of the first request and does not repeat the work. A replayed create can answer `200` where the first answered `201`. |
| The same key with a different body | Refuses with `409` and code `idempotency_conflict` or `conflict_idempotency`, and changes nothing. |
| A new key | Treats the request as new work. |

Reuse a key only to retry the exact same request. Generate a new key when you mean to do something new.

:::caution
A replay never returns a one-time secret again. When you retry a request that returns a secret once, such as creating a webhook endpoint, the replay carries `"secret": null` and `"secret_unavailable": true`. If the first response was lost, rotate the secret to get a new one.
:::

## ETag and If-Match

Two people, or two programs, can edit the same resource at once. ETags stop one from silently overwriting the other.

A versioned resource, such as a trunk, an outbound policy, a voice application or a webhook endpoint, returns an `ETag` header with every read and write. The ETag is the resource's `revision`, a quoted integer that goes up with every saved change:

```http
HTTP/1.1 200 OK
ETag: "3"
```

To change the resource, send that value back in `If-Match`:

```bash
curl -X PATCH "https://api.signals.pressone.africa/v1/voice/applications/vap_0192b6f4-7e2a-7c18-9d3b-4a5e6f0c1b28" \
  -H "Authorization: Bearer $SIGNALS_API_KEY" \
  -H "Content-Type: application/json" \
  -H 'If-Match: "3"' \
  -d '{ "name": "order-tracking-line" }'
```

Copy the ETag exactly, quotes included. The revision is only a concurrency token: it tells Signals which version you read, not whether a change has reached the network.

| What happens | Response |
| --- | --- |
| The resource has not changed since your read | The change applies, and the response carries the new ETag. |
| You leave out `If-Match` where the reference requires it | `428 Precondition Required` with code `precondition_required`. |
| The `If-Match` value is malformed, for example not a quoted integer | `400` with code `invalid_precondition`. |
| Someone changed the resource since your read | `412 Precondition Failed`. Codes include `revision_conflict` and `precondition_failed`, plus resource-specific codes such as `outbound_policy_revision_conflict`. |

After a `412`, read the resource again, reapply your change to the new version, and send it with the new ETag.

### Saved changes are live

There is no draft, preview or publish step. A create or edit to a trunk, an outbound policy, a number route, a subtrunk, a subaccount or a voice application answers `200 OK`, or `201 Created` for a create, with the resource as stored. Changes take effect within a minute. If a change cannot be applied, PressOne staff are alerted and the previous configuration keeps serving.

A trunk change that would leave the trunk unable to run is refused with `422` and a `detail` that starts with `the trunk would not apply:`. Nothing is saved.

## Conflicts

A `409 Conflict` means the request is valid but the current state of your account does not allow it. Examples:

| Code | Meaning |
| --- | --- |
| `trunk_name_conflict` | Another trunk already has that name. |
| `number_block_in_use` | The number block is still routed or delegated. The problem lists what uses it. |
| `number_quote_expired` | The price quote expired. Request a new quote. |
| `conflict_target_version` | A route target cannot take the route; `reason` says why. |

Read the `detail` and the current state of the resource, resolve the conflict, then retry with a new `Idempotency-Key`.

## Background work

Two requests start work that takes longer than one request: running a trunk test and deleting a recording. For these, Signals answers `202 Accepted`, and the `Location` header points to where the result appears.

For a trunk test, read `GET /v1/trunks/{trunk_id}/tests/{test_id}` until the run's `result` is no longer `running`:

```bash
curl https://api.signals.pressone.africa/v1/trunks/$TRUNK_ID/tests/tst_0192b7ea-3f1c-7b2d-8e4a-5c6d7e8f9a01 \
  -H "Authorization: Bearer $SIGNALS_API_KEY"
```

Read every second or two while you wait. A `passed` or `failed` result is final.

## Pagination

Every list answers a JSON object, never a bare array. The rows are under `data`. Fields that describe the whole list, such as `as_of`, totals or `currency`, sit beside `data`.

Most lists return every row in one response. Collections that grow without bound are paged instead:

| Endpoint | Page size | How to page |
| --- | --- | --- |
| `GET /v1/calls` | `limit`, 1 to 100, default 50 | `cursor` |
| `GET /v1/audit` | `limit`, 1 to 100, default 50 | `cursor` |
| `GET /v1/webhooks/deliveries` | `limit`, 1 to 100, default 100 | Narrow with `from` and `to` |

A cursor page adds `next_cursor` beside `data`: the page after this one, in the list's order. Where the list can also step back, it adds `previous_cursor`: the page before this one. Pass either value as `cursor` to move. A `null` cursor means there is no page in that direction.

```bash title="Request"
curl "https://api.signals.pressone.africa/v1/calls?window=last_24h&limit=2&cursor=eyJpZCI6ImNhbF8wMTkyIn0" \
  -H "Authorization: Bearer $SIGNALS_API_KEY"
```

```json title="Response: 200 OK (abridged)"
{
  "as_of": "2026-10-01T09:20:05Z",
  "data": [
    { "id": "cal_0192b7f2-6d81-7a3b-9c4e-1f0a8d5e2b63", "outcome": "answered" },
    { "id": "cal_0192b7f0-2a4c-7e91-8b5d-3c6f0e1a9d42", "outcome": "unanswered" }
  ],
  "total": 37,
  "answered": 31,
  "failed": 6,
  "next_cursor": "eyJpZCI6ImNhbF8wMTkyYjdmMC0yYTRjIn0",
  "previous_cursor": "eyJpZCI6ImNhbF8wMTkyYjdmMi02ZDgxIn0"
}
```

Treat cursors as opaque and keep the same filters while you follow them. An unknown filter value or an out-of-range `limit` fails with `422`.

Endpoints that take `after_sequence`, such as call events and trunk test checks, return items in order. Pass the highest sequence number you have received to get only newer items.

## Errors

Every Signals API error uses the problem format from RFC 9457, with the content type `application/problem+json`:

```http
HTTP/1.1 412 Precondition Failed
Content-Type: application/problem+json
X-Request-ID: req_0192b7ec5d2a7f3e8c1b9d4a6e0f2c51

{
  "type": "https://signals.pressone.africa/problems/revision_conflict",
  "title": "Precondition Failed",
  "status": 412,
  "detail": "The trunk changed. Read it and retry against its current ETag.",
  "code": "revision_conflict",
  "request_id": "req_0192b7ec5d2a7f3e8c1b9d4a6e0f2c51"
}
```

| Field | Meaning |
| --- | --- |
| `type` | A URL that names the problem. It always ends in the `code`. |
| `title` | The standard name of the HTTP status. |
| `status` | The HTTP status code, repeated in the body. |
| `detail` | A sentence for a person, explaining this occurrence. It can change; do not match on it. |
| `code` | A stable identifier for the problem. Match on this in your code. |
| `request_id` | The request ID, as in the `X-Request-ID` header. |

Some problems add fields:

| Field | Present on |
| --- | --- |
| `available_projects` | `400 project_context_required`: the projects you can name in `X-Project-ID`. |
| `reason` | `409 conflict_target_version`: why a route target refused the route. |
| `caused_by` | Some `503` problems: which dependency is unavailable, such as `identity_provider`. |
| `expensive_filter` | `422 validation_query_cost`: the filter that made the query too expensive. |

Problems never include internal hostnames, stack traces or secrets.

### Error codes by status

Handle errors by status first, then by `code` where you need to tell cases apart. Codes follow a pattern: `<resource>_forbidden` for 403, `<resource>_not_found` for 404, `invalid_<resource>` for 422, `<resource>_unavailable` for 503.

| Status | Meaning | Common codes | What to do |
| --- | --- | --- | --- |
| `400` | The request is malformed. | `invalid_idempotency_key`, `invalid_precondition`, `project_context_required`, `project_invalid`, `invalid_query` | Fix the request. |
| `401` | The credential is missing or not valid. | `unauthenticated` | Check the API key. Do not retry with the same key. |
| `403` | The key is valid but may not do this. | `forbidden_capability`, `trunk_diagnostics_forbidden`, `webhook_write_forbidden` | Use a key with the needed role and scope. |
| `404` | The resource does not exist or belongs to another organization. | `trunk_not_found`, `project_not_found`, `not_found` | Check the ID and the project. |
| `409` | The current state does not allow the request. | `idempotency_conflict`, `trunk_name_conflict`, `number_block_in_use` | See [Conflicts](#conflicts). |
| `412` | The resource changed since you read it. | `revision_conflict`, `precondition_failed` | Read again and reapply. |
| `422` | The request is well formed but its values are not valid. | `invalid_trunk`, `invalid_outbound_policy`, `invalid_call_filter`, `validation_subaccount`, `validation_query_cost` | Read `detail`, fix the values. |
| `428` | `If-Match` is required. | `precondition_required` | Read the resource and send its ETag. |
| `429` | A usage cap was reached. | `egress_cap_reached` | Wait and retry later. |
| `500` | Signals found inconsistent stored state. | `invalid_<resource>_state` | Contact support with the `request_id`. |
| `502`, `504` | A platform dependency failed or timed out. | `upstream_unavailable`, `upstream_timeout`, `upstream_invalid` | Retry with backoff. |
| `503` | A Signals service is temporarily unavailable. | `trunks_unavailable`, `calls_unavailable`, `identity_unavailable`, `project_context_unavailable` | Retry with backoff. |

A `404` covers both "does not exist" and "belongs to someone else", so an ID from another organization reveals nothing about that organization.

### Retrying safely

- Retry `502`, `503` and `504` with exponential backoff, starting at about one second.
- Retry network failures and timeouts the same way.
- When you retry a request that took an `Idempotency-Key`, send the same key and the same body, so the work happens once.
- Do not retry `400`, `401`, `403`, `404`, `409`, `412`, `422` or `428` unchanged. Fix the cause first.
