Developers

The same API the portal is built on.

There is no separate integration tier. Every screen in the customer portal calls the routes documented here, over the same session, so what you can build against them is exactly what the portal itself can do.

Shape

Three surfaces, one of them yours

The API also serves the voice edge and the desk phones on your desk. Neither is a route your code should ever call.

/api/v1/…

Portals and integrations. Session cookie or bearer token. This is the surface documented on this page and in the reference.

/internal/fs/…

FreeSWITCH and Kamailio, on the voice network, authenticated by a shared secret. Not reachable from outside the platform, and not part of this contract.

/p/:token/:filename

A desk phone fetching its own configuration by a single-device token in the URL. Excluded from the OpenAPI document for the same reason.

One version, v1, carried in the path on each controller rather than in a global setting, so a future v2 controller can be added beside a v1 one without moving anything that already works.

Authentication

A session cookie, or the same token as a bearer header

AuthGuard runs on every route by default. A new endpoint is protected unless it is explicitly marked otherwise, so nothing is unguarded by accident.

Signing in. POST /api/v1/auth/login sets an HttpOnly, SameSite=Lax cookie named witech_session, good for 12 hours. The same token this cookie carries is also accepted as Authorization: Bearer <token>, which is how a script or a server-to-server integration authenticates without a browser to hold the cookie.

The token itself is 32 random bytes, base64url-encoded, and the database stores only its SHA-256 digest. A stolen database backup yields no usable session.

Multi-factor. Every platform-operator role requires it and cannot opt out. It is checked before any permission is, on every request, with exactly four routes exempted so a freshly created account can still enrol: mfa/verify, mfa/enrol, mfa/enrol/confirm and logout.

Which account. The active account comes only from Session.activeTenantId, resolved fresh from the database on every request. An account id in a request body, a header or a query string changes nothing; a role revoked a minute ago no longer applies on the next call. POST /api/v1/auth/switch-tenant changes it, and refuses without a membership in the target account.

Permissions. Each route names the permissions it needs; every one of them must be held, or the request is refused with the specific permission that was missing named in the response. An account-scoped permission with no active account is refused with "Choose an account before making this request."

API tokens. A machine-credential permission exists in the role tables, but no controller issues or accepts one yet. Machine-to-machine access today is a session token like any other.

Rate limits

Per IP address, per minute, checked before the request body is read

One budget per bucket, so a burst of dashboard polling cannot spend the login budget and a login attempt cannot spend the dashboard's. Every number below is read from bucketFor() in the API's own entry point.

BucketApplies toLimit
loginPOST /api/v1/auth/login10 / 60s
auththe rest of /api/v1/auth/* (MFA, password reset)20 / 60s
enquiryPOST /api/v1/enquiries3 / 60s
signup/api/v1/signup/*5 / 60s
provisioning/p/* (desk phone config fetch)60 / 60s
health/health* and /metrics600 / 60s
defaulteverything else under /api/v1300 / 60s

A request that exceeds its bucket gets 429 with a Retry-After, and no hint about which bucket was hit. The store is in-process: with more than one API replica behind a load balancer, each replica enforces its own share of the budget.

Being straight about it

Two different list shapes, and which one you get depends on the endpoint

This is a known inconsistency, tracked as an open item to standardise. Until then, both shapes exist in production.

Keyset pages return "data"

?take=50&cursor=<id>

{ "data": [ ... ], "nextCursor": "uuid" | null, "take": 50 }

Used by the webhook endpoints and the call-flow versions list. The cap is 100 per page and lives on the server, not in the request.

Offset pages return "items"

?page=1&pageSize=50

{ "items": [ ... ], "total": 123, "page": 1, "pageSize": 50 }

Used by numbers, extensions, and the platform-operator tenants and audit-log lists. Caps vary: 100 for extensions and platform tenants, 200 for numbers and audit logs.

The public plan catalogue (GET /api/v1/plans) is neither paginated scheme: it always returns every public plan as { "items": [...] }, because the catalogue is small enough that a page size was never worth adding.

Errors

One shape, and a status code that means one thing

Every error is a standard Nest exception body. A validation failure also carries a field-by-field list.

StatusWhen
400A validation failure (with a field-by-field `errors` list), or a business rule the request broke.
401No session, an expired session, or a disabled account.
403A missing permission, MFA not yet satisfied, or no account chosen for an account-scoped request.
404Not found.
409A conflict: a taken extension number, a duplicate device, an order already in progress, or a delete blocked by dependents.
429The rate limit for that bucket was hit. The response carries a `Retry-After`.
503`/health/ready` only, when the database is unreachable.

A validation failure

{
  "statusCode": 400,
  "error": "Validation failed",
  "message": "2 fields need attention.",
  "errors": [
    { "field": "number.territory",
      "message": "Invalid enum value." },
    { "field": "idempotencyKey",
      "message": "String must contain at
        least 8 character(s)" }
  ]
}

Webhooks

Signed deliveries, verified with three headers

Manage endpoints under /api/v1/webhooks/endpoints. Every delivery to your URL carries these headers, over a secret we generate and you cannot choose.

HeaderValue
x-witech-signatureHMAC-SHA256(secret, "<timestamp>.<body>"), lowercase hex
x-witech-timestampUnix seconds the request was signed
x-witech-event-idThe event UUID, for deduplicating retried deliveries

The signed string is timestamp + "." + body, not the body alone, which is what makes the timestamp tamper-evident: an attacker replaying an old delivery cannot move the timestamp forward without invalidating the signature. Verify against the raw request body, not a re-parsed and re-serialised one, since key order and whitespace both change the digest.

The secret is shown exactly twice: when an endpoint is created, and when it is rotated. It cannot be retrieved again after that, only replaced.

Verifying a delivery (Node)

const ts = headers['x-witech-timestamp'];
const sig = headers['x-witech-signature'];
const REPLAY_WINDOW_SECONDS = 300;

const drift = Math.abs(Date.now() / 1000 - ts);
if (drift > REPLAY_WINDOW_SECONDS) return false;

const expected = crypto
  .createHmac('sha256', secret)
  .update(`${ts}.${rawBody}`)
  .digest('hex');

return crypto.timingSafeEqual(
  Buffer.from(sig, 'hex'),
  Buffer.from(expected, 'hex'),
);

Not every declared event type fires yet

The event catalogue is closed and validated by an enum, but only call.completed, number.assigned, number.unassigned and the number.port.* events are emitted by anything today. A subscription to an event with no producer yet will simply never fire.

Worked example

One request, start to finish

The public plan catalogue, unauthenticated, exactly as it is served.

Request

curl https://api.example.com/api/v1/plans

Response (field names are exact; values below are illustrative)

{
  "items": [
    {
      "id": "8f2c…",
      "code": "starter",
      "name": "Starter",
      "description": "…",
      "baseMonthlyCents": 1900,
      "perUserMonthlyCents": 0,
      "includedMinutes": 500,
      "includedNumbers": 1,
      "maxUsers": 5,
      "maxConcurrentCalls": 3,
      "recordingStorageGb": 5,
      "internationalAllowed": false,
      "currency": "USD",
      "features": [
        { "key": "call_queues", "label": "Call queues",
          "included": true, "value": null }
      ],
      "addOnPricing": {
        "numberMonthlyCents": 200,
        "numberSetupCents": 0,
        "internationalMonthlyCents": null,
        "overagePerMinuteCents": 2,
        "storagePerGbMonthlyCents": 10
      }
    }
  ]
}

No plan price is ever hard-coded in a client: every field above is read from thePlan and PlanFeature rows the billing engine itself charges from, so a price shown here is the price that will be charged.

Next

Read every route, or ask us about yours

The full reference lists method, path, permission and what each endpoint does, read straight from the controllers.

The generated OpenAPI document is served at /api/docs alongside the API itself.