Moving to public IDs (UUIDs)

Moving to public IDs (UUIDs)

The ngris API now identifies every object you can address by a uuid instead of an internal integer id. Tunnels, API keys, auth tokens, rate-limit rules, traffic policies, routing rules, RBAC clients and roles, members, invitations, SSO configs, client CAs, dedicated IPs, error templates, traffic logs and more each carry a uuid. Every reference to another object gains a <thing>_uuid field next to the old <thing>_id.

Integer ids are deprecated. They keep working during a 30-day window, then the API refuses them. Endpoints, domains, certificates and applications already used uuids in their URLs; nothing changes there except that the URLs now accept a uuid only.

  • Paths take the object's uuid: /v1/tunnels/{uuid}, /v1/keys/auth/{uuid}, /v1/firewall/rate-limits/{uuid}.
  • Request bodies and query parameters take <thing>_uuid (endpoint_uuid, role_uuid, ca_uuids) instead of <thing>_id.
  • Plans have no uuid: they are addressed by their slug. Send "plan": "team" instead of plan_id; responses carry plan next to every plan_id.
  • Responses carry uuid on every object and <thing>_uuid next to every reference. No existing field changes type; the integers are removed at the sunset.
Every mount: the change applies to /v1, /api/v1 and /api alike. There is no /v2; this is a change inside v1 with a deprecation window.
Before (deprecated)
curl -X DELETE "https://api.ngris.com/v1/keys/auth/1287" \
  -H "X-API-KEY: $NGRIS_API_KEY"
After
# 1. Read the token's uuid from the list
curl -s "https://api.ngris.com/v1/keys/auth" \
  -H "X-API-KEY: $NGRIS_API_KEY" | jq '.items[] | {uuid, description}'

# 2. Address it by uuid
curl -X DELETE "https://api.ngris.com/v1/keys/auth/019ae915-a554-7fc4-aea3-9c9ce2dd2976" \
  -H "X-API-KEY: $NGRIS_API_KEY"

Timeline

Date What happens
2026-10-14 Every route, body and query parameter accepts the uuid; every response carries uuid and the *_uuid fields. Integer ids still work, with deprecation headers.
2026-11-13 23:59:59 UTC The sunset. After this moment an integer id is refused with 400 integer_id_unsupported, and the integer id / *_id fields are removed from responses.
The sunset can come earlier. If no request has used an integer id for 7 days in a row, we may move the sunset forward. The Sunset header on every deprecated request always carries the current date, and we announce any change on this page. If your requests never get the deprecation headers, an earlier sunset doesn't affect you.
Am I affected? Check a response
# A request with an integer id gets the deprecation headers
curl -s -o /dev/null -D - \
  "https://api.ngris.com/v1/tunnels/4521" \
  -H "X-API-KEY: $NGRIS_API_KEY" | grep -iE '^(deprecation|sunset|link):'

Deprecation headers

Until the sunset, every request that sends an integer id where a uuid is expected, in the path, the body or the query string, still succeeds. Its response carries three headers:

  • Deprecation (RFC 9745): @ followed by the Unix time the deprecation took effect.
  • Sunset (RFC 8594): the HTTP date after which integer ids are refused.
  • Link: this page, with rel="deprecation".

Requests that use uuids only never get these headers. Log the headers in your client to find the calls you still have to move. Each such request is also counted on our side, which is how we know when integer use has stopped.

Response headers on a deprecated request
HTTP/2 200
content-type: application/json
deprecation: @1792022399
sunset: Fri, 13 Nov 2026 23:59:59 GMT
link: <https://ngris.com/docs/public-ids>; rel="deprecation"; type="text/html"

After the sunset

An integer id sent after the sunset, in a path, a body or a query string, is answered with 400 and the code integer_id_unsupported. The param field names the parameter that carried the integer.

Routes that already took a uuid (endpoints, domains, certificates, applications, firewall policies, metric alerts) accept a uuid only. Anything else in that path segment, an integer included, matches no route and gets a plain 404.

The integer id and *_id fields disappear from responses at the same time. Code that still reads them gets nothing.

400 after the sunset
{
  "error": "Integer ids are no longer accepted. Use the resource's uuid (returned as \"uuid\" in every response).",
  "code": "integer_id_unsupported",
  "param": "id"
}

Paths

These routes took an integer and now take the object's uuid. The integer still works until the sunset, with the deprecation headers.

Route Takes
/user/sessions/{uuid}your session's uuid
/user/invitations/{uuid}/accept, /declineinvitation uuid
/account/members/{user_uuid}, …/rolethe member's user uuid (user_uuid in the member list)
/account/invites/{uuid}, /account/roles/{uuid}invitation uuid, role uuid
/stats/users/{uuid}your own user uuid
/keys/api/{uuid}, /keys/auth/{uuid}API key uuid, auth token uuid
/tunnels/{uuid}, …/tags, /stats/tunnels/{uuid}tunnel uuid
/traffic/endpoints/{tunnel_uuid}/metricsthe tunnel uuid (the route's historical name)
/endpoints/{uuid}/oauth/providers/{provider_uuid}OAuth provider uuid
/endpoints/{uuid}/rbac/clients/{client_uuid}, …/sessions/{session_uuid}/revokeRBAC client uuid; client session uuid
/endpoints/{uuid}/rbac/roles/{role_uuid}RBAC role uuid
/endpoints/{uuid}/policies/{policy_uuid}, …/rules/{rule_uuid}traffic policy uuid; policy rule uuid
/endpoints/{uuid}/routing-rules/{rule_uuid}routing rule uuid
/firewall/rate-limits/{uuid}, …/conditions/{condition_uuid}rate-limit rule uuid; condition uuid
/traffic/logs/{uuid}, …/replay, …/analyzetraffic log uuid
/templates/{uuid}error template uuid
/sso/config/{uuid}SSO config uuid
/dedicated-ips/{uuid}/releasededicated-IP assignment uuid (an item of GET /dedicated-ips)
/dedicated-ips/groups/{uuid}/release, /dedicated-ips/requests/{uuid}/cancelgroup uuid, request uuid
/client-cas/{uuid}client CA uuid
/git/installations/{uuid}, …/reposthe linked installation's uuid (not GitHub's installation_id)
/billing/balance/topups/{uuid}/receipt.pdfledger row uuid
/public/incidents/{uuid}, /public/maintenances/{uuid}incident / maintenance uuid

A child object resolves only under the parent named in the path: a policy rule under its policy, a client session under its client, a rate-limit condition under its rule.

Find a uuid: list, then address
# Rate-limit rules: uuid next to name
curl -s "https://api.ngris.com/v1/firewall/rate-limits" \
  -H "X-API-KEY: $NGRIS_API_KEY" | jq '.rules[] | {uuid, name}'

# Toggle one by uuid
curl -X PUT "https://api.ngris.com/v1/firewall/rate-limits/019b344f-6477-7b07-8dac-7d370474eab1/toggle" \
  -H "X-API-KEY: $NGRIS_API_KEY"

Request bodies and query parameters

Send the new name. The old name still works until the sunset, with the deprecation headers.

Route Send Instead of (deprecated)
POST /user/switch-accountaccount_uuidaccount_id
PUT /account/members/{user_uuid}/role, POST /account/invitesrole_uuidrole_id
POST /account/transfer-owneruser_uuid, old_owner_role_uuiduser_id, old_owner_role_id
POST /keys/authendpoint_uuid, tunnel_uuidendpoint_id, tunnel_id
POST /endpoints, POST /certificates/self, GET /endpoints?…domain_uuiddomain_id
PUT /endpoints/{uuid}/certificatecertificate_uuidcertificate_id
POST /applications (GitHub import)git_installation_uuidgit_installation_id
POST /billing/checkout, GET /billing/preview-plan-change?…plan (the slug)plan_id
POST / DELETE /endpoints/{uuid}/rbac/membershipsclient_uuid, role_uuidclient_id, role_id
PUT /endpoints/{uuid}/rbac/registration-policydefault_role_uuiddefault_role_id
POST /endpoints/{uuid}/routing-rules/reorder, POST /firewall/policies/reorderordered_uuidsordered_ids
PUT /endpoints/{uuid}/mtlsca_uuidsca_ids
POST /endpoints/{uuid}/dedicated-ip, …/dedicated-ip-groupassignment_uuid, group_uuidassignment_id, group_id
POST /account/objectivesendpoint_uuidendpoint_id
GET /firewall/rate-limits?…, GET /traffic/metrics?…, GET /traffic/security-analytics?…endpoint_uuidendpoint_id
GET /traffic/metrics/compare?…endpoint_uuids (repeat it)endpoint_ids
GET /traffic/logs, …/export, POST /traffic/smart-searchendpoint_uuid, rate_limit_rule_uuid, since_uuid, policy_uuidendpoint_id, rate_limit_rule_id, since_id, policy_id
  • A malformed uuid is 400: {"error": "endpoint_uuid must be a uuid", "param": "endpoint_uuid"}.
  • A uuid you can't address, including another account's, is 404, worded exactly like an unknown object.
  • Both names sent for different objects is 400 id_uuid_mismatch. For arrays, the two lists must name the same objects in the same order. Send the uuid only.
  • Bodies that reject unknown fields accept the new names.
Before (deprecated)
POST /v1/keys/auth
{
  "description": "ci-bot agent",
  "endpoint_id": 184
}

PUT /v1/endpoints/{uuid}/mtls
{ "mode": "required", "ca_ids": [7, 12] }

POST /v1/billing/checkout
{ "plan_id": 2, "interval": "monthly" }
After
POST /v1/keys/auth
{
  "description": "ci-bot agent",
  "endpoint_uuid": "019bb1e4-e785-77d2-9966-c3cff31d0a0e"
}

PUT /v1/endpoints/{uuid}/mtls
{
  "mode": "required",
  "ca_uuids": [
    "019af8a2-4b16-70ea-8090-9f63343250c8",
    "019b07e7-fbab-74be-a38d-e60284dd7254"
  ]
}

POST /v1/billing/checkout
{ "plan": "team", "interval": "monthly" }
id_uuid_mismatch
{
  "error": "endpoint_id and endpoint_uuid name different objects; send endpoint_uuid only",
  "code": "id_uuid_mismatch",
  "param": "endpoint_uuid"
}

Responses

  • Every addressable object carries uuid.
  • Every reference <thing>_id gains <thing>_uuid next to it; arrays gain <thing>_uuids in the same order. A *_uuid field is omitted when the reference is empty.
  • Every plan_id gains plan (the slug); GET /plans lists each plan's slug.
  • Firewall policies and conditions keep their string id (it already was a uuid) and gain uuid with the same value.
  • Rows that only appear in lists and that nothing addresses carry no uuid, for example account membership rows, activity and audit-log entries, metric-alert events, incident updates and tunnel agents. Their integer id is removed at the sunset.

Until the sunset responses carry both forms. Switch your code to the uuid fields now; the integer fields are deprecated and disappear at the sunset.

Still integers, by design

These are not our internal ids and don't change: Stripe ids (stripe_*, payment_method_id, invoice and tax ids), the agent's own agent_id, request_id and connection_id, the DNS provider's record_id, GitHub's installation_id and repository ids, and counts, limits, ports and priorities.

GET /v1/keys/auth (during the window)
{
  "items": [
    {
      "id": 1287,
      "uuid": "019ae915-a554-7fc4-aea3-9c9ce2dd2976",
      "user_id": 1024,
      "user_uuid": "019a52c2-ca1e-78ba-ab5c-50362cc29134",
      "endpoint_id": 184,
      "endpoint_uuid": "019bb1e4-e785-77d2-9966-c3cff31d0a0e",
      "description": "ci-bot agent",
      "status": "active"
    }
  ]
}
After the sunset
{
  "items": [
    {
      "uuid": "019ae915-a554-7fc4-aea3-9c9ce2dd2976",
      "user_uuid": "019a52c2-ca1e-78ba-ab5c-50362cc29134",
      "endpoint_uuid": "019bb1e4-e785-77d2-9966-c3cff31d0a0e",
      "description": "ci-bot agent",
      "status": "active"
    }
  ]
}

Other behaviour changes

  • Another account's object is a 404. A uuid or integer of an object outside your active account is answered exactly like an unknown id. A few routes used to answer 403 there and now answer 404: the endpoint API-key routes, dedicated-IP release and group release, and client-CA delete.
  • Members: removing or re-roling a user who isn't a member of the account is 404 Member not found (it was 400).
  • Case: uuids are accepted in upper or lower case everywhere. Domain, certificate and identity-verification routes used to accept lower case only.
  • References are checked: endpoint_uuid / tunnel_uuid on POST /keys/auth, endpoint_uuid on POST /account/objectives and default_role_uuid on the RBAC registration policy must name an object you can address, otherwise the request is 404. The legacy integers are checked the same way.

Webhooks, exports, emails and links

Ids also leave the API in webhooks, files and links. They move to uuids the same way:

  • Alert webhooks: the body gains endpoint_uuid next to endpoint_id (rule_uuid was already there). endpoint_id is removed at the sunset; read endpoint_uuid. Slack and Discord messages name the endpoint by its uuid. See Metrics & alerts.
  • Traffic-log export: each JSON entry gains uuid and the CSV gains a UUID column (the last one, so no existing column moves). The JSON id and the CSV ID column are removed at the sunset. See Export Traffic Logs.
  • Data export (POST /user/data-export): every object carries uuid and every reference <thing>_uuid; the integers are removed at the sunset.
  • Account invitation emails link to the invitation by its uuid. A link from an email sent earlier still works until the invitation expires (14 days): the dashboard forwards it to the uuid link.
  • Status page: incidents and maintenances live at /status/incidents/<uuid> and /status/maintenances/<uuid>. An older integer link redirects there until the sunset.
  • Per-region dedicated-IP groups publish their geo name as grp-<group uuid>.<geo zone> (the geo_fqdn field).
  • Support desk: ticket messages name other tickets by uuid, and campaign emails link through the link's uuid; links in emails sent earlier keep working.
Alert webhook body (during the window)
{
  "rule_uuid": "019c2f7a-1d3e-7b40-9a6c-5e8f0b2d4c71",
  "name": "API p95 latency",
  "status": "firing",
  "metric": "p95_latency_ms",
  "operator": ">",
  "threshold": 500,
  "value": 742.5,
  "window_seconds": 300,
  "observed_at": "2026-10-13T09:00:00Z",
  "endpoint_id": 184,
  "endpoint_uuid": "019bb1e4-e785-77d2-9966-c3cff31d0a0e",
  "test": false
}

MCP server, Python SDK and Kubernetes operator

MCP server

Tools take and return uuids. A numeric id input is accepted as a deprecated alias until the sunset. Assistants re-read tool schemas every session, so nothing to do there; only saved prompts or scripts that quote a numeric id need the uuid instead. See MCP Server.

Python SDK

From ngris 0.2.0 every method takes and returns string uuids: client.tunnels.get(tunnel_uuid), client.api_keys.delete_auth_token(token_uuid), client.billing.checkout(plan="team"), client.endpoints.assign_certificate(endpoint_uuid, certificate_uuid=…). Models expose uuid and the *_uuid fields; their integer fields are optional and deprecated. An integer passed to a method still works until the sunset and emits a DeprecationWarning. Upgrade with pip install -U ngris before the sunset.

Kubernetes operator

From operator 0.2.0 the custom resources record the ngris objects they own by uuid. Resources created by an older operator still hold an integer id in their status; on its next reconcile the new operator looks up the matching uuid and stores it. That lookup needs the integer ids, so upgrade the operator before the sunset.

Dashboard and the agent

Nothing to do in the dashboard. Agent auth token values don't change, so nothing changes on the machines that run the agent.

Python SDK 0.2.0
from ngris import Ngris

client = Ngris(api_key="<your_api_key>")

tok = client.api_keys.create_auth_token(
    description="ci-bot agent",
    endpoint_uuid="019bb1e4-e785-77d2-9966-c3cff31d0a0e",
)
print(tok["uuid"])

client.api_keys.delete_auth_token(tok["uuid"])
client.billing.checkout(plan="team")

Migration checklist

  1. Find the calls that still send integers: log the Deprecation and Sunset response headers, or search your code for _id fields and numeric path segments.
  2. Read the uuid of each object from its list or create response (uuid), and store it instead of the integer.
  3. Build paths from the uuid; send *_uuid fields and plan slugs in bodies and query strings.
  4. Read uuid / *_uuid from responses; stop reading id / *_id.
  5. Upgrade the Python SDK and the Kubernetes operator to 0.2.0 or later.
  6. Done when none of your requests gets the deprecation headers any more, before 2026-11-13 23:59:59 UTC.

Every page of the API reference shows the uuid form. Questions: contact us.