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.
Base URL
Send every Signals API request to:
Code
Paths in the API reference 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.
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. |
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. |
X-Project-ID | Optional | The project the request acts on. See Choosing a project. |
X-Request-ID | Optional | Your own ID for the request. See 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:
Code
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.
Code
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:
Code
| 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 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:
Code
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.
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:
Code
To change the resource, send that value back in If-Match:
Code
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:
Code
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.
Code
Code
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:
Code
| 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. |
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,503and504with 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,422or428unchanged. Fix the cause first.