This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

Integrate with SPEKTRA Edge

Automate SPEKTRA Edge from your own systems.

This section is for developers calling SPEKTRA Edge from their own applications, scripts, and automation. If you are setting up devices or deploying applications from the dashboard, start with Learn instead. If you want to build your own service that runs on the platform, see Build a service.

Everything the platform does, it does through its API. The dashboard reads and writes the same API you will; so does the cuttle CLI. Nothing you see in the dashboard is off-limits to automation, and nothing you automate is second-class.

In this section

Read in order the first time — each page assumes the one before it.

  1. Get started — authenticate and make your first call, in about ten minutes.

  2. Authentication — service accounts, API keys, and how to hold credentials safely in CI and in long-running services.

  3. Resource names — how resources are addressed, why most of them carry a region, and how to build a name you have never seen before.

  4. Reading resources — the filter language, field masks and views, ordering, and pagination. The largest page here, and the one that pays for itself fastest.

  5. Watching for changes — subscribe to a live stream of changes instead of polling.

  6. Errors and limits — what the platform returns when something is wrong, which failures are worth retrying, and the two update-time mistakes that silently destroy data.

  7. Deployment status — drive a rollout from your own code, and read back whether it actually worked.

Choosing a protocol

The same API is exposed three ways. Pick once, at the start.

Protocol Use it when Caveats
gRPC You are writing a service, a daemon, or anything long-lived. This is the native protocol. Needs a gRPC library and the .proto files or an SDK.
gRPC-Web You are calling from browser JavaScript. Served through a proxy; a browser cannot speak plain gRPC.
REST/JSON You are writing a shell script or a small one-off tool. Generated automatically from the gRPC definitions. See the warning below.

gRPC runs over HTTP/2, so its traffic passes through HTTP proxies and HTTP/2-compliant firewalls like any other HTTPS traffic. Choosing the native protocol does not usually mean asking your network team for an exception.

Streaming calls — including watch — are natural over gRPC and awkward or unavailable over REST. If you need a live view of anything, choose gRPC.

SDKs

Each SDK ships the .proto definitions so you can generate a client for any language, plus a ready-made client library for Go. For how to use the libraries, read the README in the repository — that is where installation and usage for each SDK is documented. Per-service API reference lives in docs/apis inside each repository, and on this site under Service APIs.

SDK Contents
edgelq-sdk The main SDK: IAM, devices, applications, monitoring, logging, and the rest of the core services.
watchdog-sdk Service Experience Insights resources such as Probe and ProbingTarget. SEI also uses IAM and monitoring from the main SDK.
goten-sdk The Goten framework types the other two SDKs are built on. You will rarely import it directly.
  • cuttle CLI — the fastest way to explore the API by hand and to check what a request returns before you write code against it.

1 - Get started

Create an automation identity and make your first API call.

This page takes you from nothing to a working, authenticated API call. It uses the cuttle CLI to create the credentials and curl to make the call, so you can follow it without writing any code first.

What you need

  • an active project, and permission to create service accounts and role bindings in it — the Owner role is enough
  • the cuttle CLI, installed and authenticated
  • your project ID and the region your project uses, both visible in the dashboard

Set these once so the commands below can be pasted as-is:

PROJECT=your-project
REGION=us-west2

Create an identity for your automation

Automation authenticates as a service account, not as you. Your own login is tied to a human who may change roles or leave; a service account belongs to the project and can be granted exactly the access its job requires.

Service accounts are regional, so they need a region as well as a project.

cuttle iam create service-account automation \
  --project $PROJECT --region $REGION

The service account gets an email-style identifier derived from its name, and you will need it in a moment:

automation@your-project.us-west2.serviceaccounts.iam.edgelq.com

Create an API key

A service account is an identity; a key is how you prove you are it. For scripting, ask for the API_KEY algorithm, which produces a single bearer token instead of a keypair.

cuttle iam create service-account-key api-key \
  --parent projects/$PROJECT/regions/$REGION/serviceAccounts/automation \
  --algorithm API_KEY \
  -o json

Look for apiKey in the response and store it somewhere safe.

Grant it access

A brand-new service account can authenticate but do nothing. Bind a role to it to say what it may do, and where.

Start with read-only. viewer grants read access across the core services, which is all this walkthrough needs and a good default for a first integration — you can widen it later once you know which writes you actually make.

cuttle iam create role-binding automation-viewer \
  --parent projects/$PROJECT \
  --member "serviceAccount:automation@$PROJECT.$REGION.serviceaccounts.iam.edgelq.com" \
  --role "services/iam.edgelq.com/roles/viewer"

To let the automation create and modify resources instead, bind services/iam.edgelq.com/roles/admin-operator for full access to the core services, or a narrower operator role. See Roles for what each one grants, and prefer the narrowest role that works.

Make your first call

Each service answers on its own host, under the shape {service}.apis.edgelq.com. Read your project back from IAM:

export API_KEY=<the apiKey value from above>

curl -s -H "Authorization: Bearer $API_KEY" \
  "https://iam.apis.edgelq.com/v1/projects/$PROJECT"

Authentication is a single header — Authorization: Bearer $API_KEY — for every service and every call.

Now read something regional. Devices live under a project and a region, so the path carries both:

curl -s -H "Authorization: Bearer $API_KEY" \
  "https://devices.apis.edgelq.com/v1/projects/$PROJECT/regions/$REGION/devices"

And narrow it with a filter, URL-encoded:

curl -s -H "Authorization: Bearer $API_KEY" \
  --data-urlencode 'filter=status.connectionStatus="CONNECTED"' \
  --get \
  "https://devices.apis.edgelq.com/v1/projects/$PROJECT/regions/$REGION/devices"

That is the whole authentication story. What remains is learning to address resources you have not seen before, and to ask for exactly the data you want — which is what the rest of this section covers.

Writing your first code

For anything longer-lived than a script, use gRPC rather than REST. The quickest route is the Go client in edgelq-sdk; for other languages, generate a client from the .proto files the SDK ships.

Whichever you choose, the credential handling is the same, and Authentication covers the service-account key file that gRPC clients normally use in place of a bearer token.

Troubleshooting

Response Usual cause
401 / UNAUTHENTICATED Missing or malformed Authorization header, or a key that has been deleted.
403 / PERMISSION_DENIED No role binding covers this call yet, or the binding has not settled. Check the role and the scope you bound it in.
404 / NOT_FOUND Usually a name problem, not a missing resource — most often a missing regions/{region} segment. See Resource names.
429 / RESOURCE_EXHAUSTED A project limit is reached. See Errors and limits.

Next steps

2 - Authentication

Service accounts, key types, and how to hold credentials safely.

Every call to SPEKTRA Edge is made by a principal — a specific identity the platform can name, and whose access it can evaluate. Getting automation right is mostly a matter of choosing the right kind of principal and then keeping its credentials somewhere sensible.

Who your code should be

Principal Created for Use for automation?
User A human, backed by the identity provider. No. Tied to a person who may change roles or leave.
Service account A workload. Belongs to a project, in a region. Yes. This is the intended identity for automation.
Group A set of users or service accounts, addressed by one email. Useful for granting access to several principals at once, not as a caller.

A service account is regional, and its name reflects that:

projects/{project}/regions/{region}/serviceAccounts/{serviceAccount}

It also has an email-style identifier, which is what role bindings refer to:

{serviceAccount}@{project}.{region}.serviceaccounts.iam.edgelq.com

Create one per job instead of one shared “automation” account — see Service accounts for why, and for how the account model works in general.

Key types

A service account is an identity; a key is how a caller proves it holds that identity. Choose the algorithm when you create the key.

Algorithm What you get Suits
API_KEY A single bearer token. Shell scripts, CI jobs, REST calls, quick tooling.
RSA_2048 An RSA keypair; the private key is returned to you. gRPC clients and long-running services. RSA_1024 and RSA_4096 are also accepted.
cuttle iam create service-account-key api-key \
  --parent projects/$PROJECT/regions/$REGION/serviceAccounts/automation \
  --algorithm API_KEY \
  -o json

You can also bound a key in time with validNotBefore and validNotAfter, which is the cleanest way to guarantee a credential issued for a short-lived job cannot outlive it.

Presenting a credential

REST and gRPC-Web take an API key as a bearer token:

Authorization: Bearer {API_KEY}

gRPC clients normally use a service account key file instead. This is the same JSON format cuttle stores, so the SDK clients can consume the file that cuttle iam create service-account-key writes without conversion. The device agent uses the same mechanism, which is why a device’s credentials look like a service account key on disk — because that is what they are.

Granting access

Authenticating tells the platform who you are; it grants nothing on its own. A new service account can make calls and will be denied all of them until a role binding gives it a role in a scope.

cuttle iam create role-binding automation-viewer \
  --parent projects/$PROJECT \
  --member "serviceAccount:automation@$PROJECT.$REGION.serviceaccounts.iam.edgelq.com" \
  --role "services/iam.edgelq.com/roles/viewer"

Three parts matter, and each is a common source of confusion.

The member string is typed. The prefix is part of the value:

Member Meaning
user:{email} One human.
serviceAccount:{email} One service account.
group:{email} Everyone in a group.
domain:{domain} Anyone with an identity in that domain.
allAuthenticatedUsers Any authenticated principal.
allUsers Anyone at all, including unauthenticated callers.

The last two deserve care: they are how genuinely public resources are exposed, and almost never what you want for automation.

The scope is the --parent. A role binding applies at an organization, a project, or a service, and inherits downwards only. Granting a role on a project grants nothing on its parent organization or on a sibling project. See Accounts & access for the full model.

Bindings settle asynchronously. A platform controller applies them, so expect up to a minute or two before a new binding takes effect. Treat an immediate PERMISSION_DENIED after granting access as “not yet”.

Restricting where a credential works

A role binding can carry conditions that constrain it beyond the role and scope; an IP condition is the most useful for automation. See Service accounts.

Holding credentials safely

Keys are additive, so rotation needs no downtime, and the storage rules are the same whether a key is used from CI or from a long-running service. Service accounts covers both, along with where not to put key material.

One point is specific to writing code against the API: the credential your automation uses to call SPEKTRA Edge is a different thing from the values your workloads need at runtime. For registry credentials and application configuration, use the secrets service instead of baking them into an image.

Next steps

3 - Resource names

How resources are addressed, and how to build a name you have not seen before.

Every resource on SPEKTRA Edge has one canonical name, and that name is the only identifier you need. It is the path in a REST URL, the name field in a gRPC request, and the argument you pass to cuttle. Learn to construct one and most of the API stops needing to be looked up.

The shape of a name

A name is an alternating sequence of collection and identifier segments, reading left to right from the outermost scope inward:

projects/acme-retail/regions/us-west2/devices/till-004

which is to say: the device till-004, in region us-west2, of project acme-retail. Collections are plural and lowerCamelCase; identifiers are yours.

Because the structure is regular, you can read the parent of any resource by dropping the last two segments — the parent of that device is projects/acme-retail/regions/us-west2.

Name scopes

How many segments a name has depends on the resource, and this is the single most common cause of a confusing NOT_FOUND. There are four patterns.

Pattern Example Notes
Global projects/acme-retail Projects, organizations, and read-only catalogs such as device types and OS versions.
Project projects/acme-retail/distributions/pos-app Project-wide, not tied to a region.
Project and region projects/acme-retail/regions/us-west2/devices/till-004 Most operational resources.
Nested under another resource projects/acme-retail/regions/us-west2/serviceAccounts/automation/serviceAccountKeys/ci The parent is itself a full name.

Some resources nest more deeply than you might expect. An alerting condition lives under a policy, and an alert lives under a condition:

projects/{project}/regions/{region}/alertingPolicies/{policy}/alertingConditions/{condition}/alerts/{alert}

A few resources accept more than one kind of parent, though any single instance has exactly one. A role binding can be scoped to a project, an organization, or a service:

projects/{project}/roleBindings/{roleBinding}
organizations/{organization}/roleBindings/{roleBinding}
services/{service}/roleBindings/{roleBinding}

When in doubt, the per-resource entry under Service APIs lists the name patterns it accepts.

Names are permanent

A name identifies a resource for its whole life and cannot be changed. Anything you might want to edit later — a human-readable label, a location, ownership — belongs in a field such as displayName, not in the name.

On create you may either supply the final identifier or omit it and let the platform generate one. Supply it when the name should be predictable from something you already know, such as a store number; omit it when the resource is one of many and you will find it by filtering on labels instead.

Identifier segments are constrained. For most resources the last segment must match:

[a-z][a-z0-9\-]{0,28}[a-z0-9]

Lower case, starting with a letter, ending alphanumeric, at most 30 characters. Some resources differ — service accounts also allow underscores and permit longer names — so check the resource’s reference entry before generating identifiers programmatically.

Wildcards in collection requests

When reading a collection you can substitute - for an identifier to mean “across all of them”. This is how you query a whole project without knowing its regions:

# Devices in one region
cuttle devices list devices --parent projects/$PROJECT/regions/us-west2

# Devices in every region of the project
cuttle devices list devices --parent projects/$PROJECT/regions/-

It works at any level, which matters most for the deeply nested resources. Listing alerts across every policy and condition in a project would otherwise require enumerating both:

cuttle monitoring list alerts \
  --parent "projects/$PROJECT/regions/-/alertingPolicies/-/alertingConditions/-"

Wildcards apply to reads. Creating, updating, or deleting requires a fully specified name.

From a name to a REST URL

REST paths are the resource name with a version prefix, so once you can build a name you can build a URL. Every service answers on its own host, shaped {service}.apis.edgelq.com.

The version prefix belongs to the service, not to the platform, so do not assume v1 everywhere. Most services are on v1, but Monitoring is on v4 (https://monitoring.apis.edgelq.com/v4/...). Check the service’s page under API reference before building URLs by hand.

Operation Method and path
Get GET /v1/projects/{project}/regions/{region}/devices/{device}
List GET /v1/projects/{project}/regions/{region}/devices
Create POST /v1/projects/{project}/regions/{region}/devices
Update PUT /v1/projects/{project}/regions/{region}/devices/{device}
Delete DELETE /v1/projects/{project}/regions/{region}/devices/{device}

Anything that is not plain CRUD is a verb appended after a colon, which is why these URLs look unusual at first:

GET  /v1/projects/{project}/regions/{region}/devices:search
GET  /v1/devices:batchGet
POST /v1/projects/{project}/regions/{region}/devices:watch
POST /v1/projects/{project}/regions/{region}/devices/{device}:getDedicatedEndpoints

Two consequences follow. batchGet is not scoped to a parent — it takes the names you want as parameters, so it can span regions in one call. And some read-only operations are POST, because their request does not fit in a URL; projects:listMy is the one you are most likely to meet.

Regions

A project belongs to a region, and most operational resources are created within one. The region in a name is not a routing hint you may omit — it is part of the resource’s identity, and the same identifier in two regions is two different resources.

Practically:

  • Use the region your project reports. It is shown in the dashboard and in the project resource itself.
  • To search across regions, use the - wildcard instead of looping.
  • Secrets do not replicate across regions, so a multi-region fleet needs the secret created in each region its devices occupy. See Secrets.

Next steps

4 - Reading resources

Filters, field masks, ordering, and pagination.

Read requests across SPEKTRA Edge share the same four controls, whichever service and whichever protocol you use:

Control Question it answers
filter Which resources?
view and fieldMask Which fields of each?
orderBy In what order?
pageSize and pageToken How many at a time?

Learn them once and every collection in the platform is queryable. Getting them right is also the difference between an integration that stays fast at ten thousand devices and one that does not.

Filtering

A filter is a SQL-flavored expression evaluated server-side.

status.connectionStatus="CONNECTED" AND metadata.labels CONTAINS "site:berlin"

Field paths are the resource’s fields in lowerCamelCase, dotted to reach nested messages, exactly as they appear in the JSON representation. String values must be double-quoted. Keywords are case-insensitive, so AND and and are equivalent.

AND is also implicit — adjacent conditions are combined without it, and + works as an alias. These three are the same filter:

spec.enabled=true AND status.state="READY"
spec.enabled=true status.state="READY"
spec.enabled=true + status.state="READY"

You can annotate long filters: -- begins a comment that runs to the end of the line, and newlines are insignificant.

-- tills that have stopped reporting
status.connectionStatus="DISCONNECTED"
metadata.labels CONTAINS "role:till"

Operators

These are implemented and safe to rely on:

Operator Example
= != < > <= >= spec.osVersion!="1.0.2"
IN status.state IN ["READY","DEGRADED"]
CONTAINS (alias HAS) metadata.labels CONTAINS "site:berlin"
CONTAINS ANY metadata.labels CONTAINS ANY ["site:berlin","site:hamburg"]
CONTAINS ALL metadata.labels CONTAINS ALL ["role:till","env:prod"]
IS NULL, IS NOT NULL status.lastSeenTime IS NOT NULL
AND (implicit, or +) see above

List arguments accept either brackets or parentheses — IN ["a","b"] and IN ("a","b") are the same.

There is one further restriction. When comparing a name or reference field against a value containing a - wildcard, only = is available; anything else is rejected.

Values

Type Form
String "berlin" — always double-quoted
Number 42, -1.5, 1e3
Boolean true, false
Null NULL
Map {"key":"value"}

Timestamps are strings in RFC 3339:

metadata.createTime>"2026-01-01T00:00:00Z"

Labels

Labels are a map, and the whole map is one field path. Match an entry with CONTAINS and a key:value string, not by indexing into it:

metadata.labels CONTAINS "site:berlin"

This is why labels are the practical way to segment a fleet: one indexed field supports the queries, the alert groupings, and the deployment targeting.

If a label key itself contains a dot, escape it with a backslash so the parser does not read it as a path separator.

Unions and negation

Because OR and NOT do not execute, express them client-side:

  • Union — run one query per branch and merge the results, de-duplicating on name. Often a single IN or CONTAINS ANY collapses the branches anyway, which is cheaper; check for that first.
  • Negation — invert the operator where you can. NOT (state="READY") becomes state!="READY"; NOT IN ["a","b"] usually has to become a client-side filter over a broader query.

Prefer collapsing to IN/CONTAINS ANY over multiple round trips whenever the branches differ only in a value.

Choosing which fields you get

By default a read returns a standard set of fields. Two controls change that, and they combine; they are not alternatives.

view picks a baseline:

View Returns
NAME Only the resource name.
BASIC A commonly useful subset. The usual default.
DETAIL More, including heavier fields.
FULL Everything.

fieldMask is additive on top of the view — a list of field paths to include as well. This trips people up: setting a field mask does not restrict you to those fields, it adds them to whatever the view already returns. For a minimal response, combine view=NAME with a field mask of exactly what you need:

curl -s -H "Authorization: Bearer $API_KEY" --get \
  --data-urlencode 'view=NAME' \
  --data-urlencode 'fieldMask=status.connectionStatus,metadata.labels' \
  "https://devices.apis.edgelq.com/v1/projects/$PROJECT/regions/$REGION/devices"

The saving is real. Device resources carry substantial hardware inventory and status; asking for two fields instead of all of them changes both response size and latency materially across a large fleet.

Ordering

orderBy takes field paths with an optional direction, asc (the default) or desc:

orderBy=metadata.createTime desc

Ordering must be stable for pagination to be correct, so prefer a field that is unique or append a tie-breaker.

Pagination

Set pageSize, then follow the tokens the server returns. Never assume the server honoured your page size — it may return fewer, and a short page does not mean the last page. The only reliable end-of-results signal is an empty nextPageToken.

TOKEN=""
while : ; do
  RESP=$(curl -s -H "Authorization: Bearer $API_KEY" --get \
    --data-urlencode "pageSize=100" \
    --data-urlencode "pageToken=$TOKEN" \
    "https://devices.apis.edgelq.com/v1/projects/$PROJECT/regions/$REGION/devices")

  echo "$RESP" | jq -r '.devices[]?.name'

  TOKEN=$(echo "$RESP" | jq -r '.nextPageToken // empty')
  [ -z "$TOKEN" ] && break
done

Responses also carry prevPageToken, so a cursor can be walked in either direction.

If you need to render “page 3 of 47”, set includePagingInfo=true and the response adds currentOffset and totalResultsCount. It costs the server extra work to count, so leave it off when you are just iterating.

Keep pageSize modest — a few hundred at most. Large pages increase the chance of a timeout on a slow query, and a retry then repeats the whole page.

Some resources also expose Search, which takes a free-text phrase in addition to the controls above:

GET /v1/projects/{project}/regions/{region}/devices:search?phrase=till

Use Search for a human typing into a box, and List with a filter for anything programmatic. Search trades exactness for recall, which is the wrong trade for automation.

Reading across regions

Substitute - for the region to query every region a project uses:

GET /v1/projects/{project}/regions/-/devices

BatchGet is the other cross-region read — it takes names instead of a parent, so one call can fetch resources from several regions at once.

Nested request fields over REST

The four controls above are flat, but some reads take structured request fields. Over gRPC you set them as nested messages; over REST, each leaf becomes a dotted query parameter. Reading time series is the case you are most likely to hit:

curl -s -H "Authorization: Bearer $API_KEY" --get \
  --data-urlencode 'filter=metric.type="devices.edgelq.com/device/cpu/usage"' \
  --data-urlencode 'interval.startTime=2026-09-01T00:00:00Z' \
  --data-urlencode 'interval.endTime=2026-09-02T00:00:00Z' \
  --data-urlencode 'aggregation.alignmentPeriod=60s' \
  --data-urlencode 'aggregation.perSeriesAligner=ALIGN_MEAN' \
  --data-urlencode 'aggregation.crossSeriesReducer=REDUCE_NONE' \
  "https://monitoring.apis.edgelq.com/v4/projects/$PROJECT/timeSeries"

Three things to note. The field names are the JSON ones, so alignment_period in the .proto is aggregation.alignmentPeriod here. interval.endTime is required while interval.startTime is optional and defaults to the end time, so omitting the start gives you an empty window, not everything. And durations are strings with a unit suffix, such as 60s.

For what the aligners and reducers actually compute, see the Monitoring API reference. For the metric types you can filter on, see the device metrics reference.

Trying it interactively

Every control here has a cuttle flag, which makes the CLI the fastest way to iterate on a filter before committing it to code:

cuttle devices list devices \
  --parent projects/$PROJECT/regions/- \
  --filter 'metadata.labels CONTAINS "site:berlin"' \
  --field-mask status.connectionStatus \
  --order-by 'metadata.createTime desc' \
  --page-size 50 \
  -o json

See Operating with the cuttle CLI for the CLI-side detail.

Next steps

5 - Watching for changes

Subscribe to a live stream of changes instead of polling.

Every collection you can list, you can also watch. Instead of asking repeatedly what changed, you open a stream, receive a snapshot of the current state, and then receive each change as it happens.

This matters more at the edge than in most systems. A fleet of ten thousand devices polled once a minute is ten thousand requests a minute to learn that almost nothing happened. The same information arrives over one watch stream, at the moment it becomes true.

Watch is a server-streaming call, so use gRPC. A REST watch endpoint exists, but a shell or browser client cannot consume a long-lived stream usefully.

The shape of a stream

A watch stream has two phases, and the transition between them is explicit.

  1. Snapshot — the current state of your query, possibly split across several responses.
  2. Incremental — one response per change, for as long as you stay connected.

The isCurrent flag marks the boundary. Accumulate responses while it is false; when it is true, combine what you have and you hold a complete picture as of the moment you asked. Every later response with isCurrent true similarly completes an incremental batch.

So the client loop is always the same: buffer changes, and apply the buffer when isCurrent arrives.

Each change carries a type — added, modified, removed, or current — and removals matter as much as additions. A device that no longer matches your filter is reported as removed even though it still exists, because it has left your query.

Choosing a watch type

There are two, and picking the wrong one is the main source of difficulty.

Property STATEFUL (default) STATELESS
Ordering and paging Supported Not supported
Resume after disconnect Not supported Supported, via a resume token
Result count Capped by page size Never capped
Reset and count handling Handled by the server Your responsibility
Intended for UI views over a bounded page Controllers and syncing over a whole collection

The trade is between paging and resumption, and you cannot have both.

STATEFUL keeps per-session state on the server, which is what allows ordering and page tokens. That state is discarded the moment the connection drops, so there is nothing to resume against — a reconnect starts with a fresh snapshot. In exchange, the server absorbs the hard parts: resets and count reconciliation never reach you. Use it when you are backing a view that shows a page of results in a defined order.

STATELESS keeps no session state, so it can stream a collection of any size and hand you a resume token to reconnect with. The cost is that ordering and paging are unavailable, and you must handle the reset and count signals described below. Use it for anything that maintains a durable local view — a controller, a cache, a synchronizer.

If you are writing a controller, you want STATELESS. If you are populating a table in a UI, you want STATEFUL.

Chunking

Set maxChunkSize to bound how many changes arrive in a single response; the maximum accepted is 100. For STATELESS watches the server applies its own default if you do not choose one, so expect chunked responses either way and rely on isCurrent, not on any response being complete.

Handling a STATELESS stream correctly

These three signals only appear on stateless watches, and ignoring them is how a long-running integration drifts out of sync with reality. Each arrives on its own — never alongside isCurrent or each other.

isSoftReset

The server hit a transient problem. Discard only the changes you have buffered since the last isCurrent — your applied state is still good, and your resume token is still valid.

buffered = []   # applied state and resume token untouched

isHardReset

The server cannot reconcile your view. Drop everything: your applied state and your resume token both. A fresh snapshot follows, and it arrives as changes of type current.

state = {}
resumeToken = None
buffered = []

snapshotSize

Occasionally the server sends a response with no changes and a snapshotSize count, meaning “you should be holding this many resources”. Compare it with your own count. A mismatch is not a warning to log — it means your view is wrong, and the correct response is to reconnect and resnapshot. Ignore the field when its value is negative, which means unpopulated.

This is a cheap consistency check that catches bugs in your own change application, so implement it even though nothing forces you to.

Resuming

Keep the most recent resumeToken — it arrives on responses where isCurrent is true — and pass it on reconnect to continue from where you stopped instead of resnapshotting the collection. Discard it on a hard reset.

Treat a resume token as opaque and short-lived. If the server rejects it, fall back to a fresh snapshot; that path will run eventually, so it needs to work.

Watching one resource

A single resource can be watched too, which is the efficient way to track one device’s status without polling it:

POST /v1/projects/{project}/regions/{region}/devices/{device}:watch

Filters on a watch

Watch accepts the same filter, view, and fieldMask controls as reading resources, with one significant restriction: a single watch request cannot express OR. As with list queries, AND composition is what executes.

Where you need a union, open one watch per branch and merge the streams. The Go SDK includes a higher-level watcher that does exactly this — managing several concurrent queries, presenting one event channel, and hiding brief reconnections — so reach for that before building it yourself.

Practical advice

  • Make change application idempotent. Resets and reconnects mean you will occasionally apply the same change twice. Key your state by resource name and overwrite rather than append.
  • Use a field mask. A watch stream carries the same payload weight as a list response, on every change. If you only track connection status, ask only for connection status.
  • Expect reconnections as routine. Deploys, rebalancing, and network blips all end streams. A watch client that treats a dropped connection as an error worth alerting on will alert constantly; reconnect with backoff instead.
  • Do not mix polling and watching for the same data. Choose one source of truth, or you will spend time debugging which one was right.

Next steps

6 - Errors and limits

What the platform returns when something is wrong, and which failures to retry.

SPEKTRA Edge reports failures as gRPC status codes. Over REST they arrive as the corresponding HTTP status, so the same small set of codes covers both.

Reading a failure

gRPC HTTP Meaning Retry?
INVALID_ARGUMENT 400 Malformed request — a bad filter, an ill-formed name, a value that fails validation. No. Fix the request.
UNAUTHENTICATED 401 No usable credential. No.
PERMISSION_DENIED 403 Authenticated, but not allowed. No, unless the grant is new — see below.
NOT_FOUND 404 No resource at that name. No.
ALREADY_EXISTS 409 An identifier is taken. No.
ABORTED 409 A compare-and-swap precondition failed. Yes, after re-reading.
FAILED_PRECONDITION 400 The resource is in the wrong state for this call. No, not without changing something.
RESOURCE_EXHAUSTED 429 A project limit is reached. No. See Limits.
UNIMPLEMENTED 501 The operation, or that part of a filter, is not supported. No.
UNAVAILABLE 503 Transient — a restart, a rebalance, a network blip. Yes.
DEADLINE_EXCEEDED 504 The call outran its deadline. Yes, with care.
INTERNAL 500 A server-side fault. Yes, once or twice with backoff.

Three of these mean something more specific than their name suggests.

NOT_FOUND is usually a name problem. Before concluding a resource is missing, check the name — a dropped regions/{region} segment is by far the most common cause. See Resource names.

UNIMPLEMENTED from a read is usually the filter. The filter grammar accepts OR, NOT, NOT IN, LIKE, and IS NAN, but the query engine does not execute them. Such a filter parses and then fails here. See Reading resources.

PERMISSION_DENIED right after granting access usually means “not yet”. Role bindings are applied by a platform controller, not at write time, so allow a minute or two before treating a denial as real. See Accounts & access.

Updates

Both of the following destroy data silently, returning a perfectly successful response.

Omitting the update mask replaces the whole resource

updateMask lists the fields to change. Without it, the entire resource is replaced by the body you sent — so a read-modify-write that posts back a partial object clears every field it omitted.

PUT /v1/projects/{project}/regions/{region}/devices/{device}
{"name": "...", "displayName": "Till 4"}

That request clears every field absent from the body. Naming the field you mean to change confines the write to it:

PUT /v1/projects/{project}/regions/{region}/devices/{device}?updateMask=displayName
{"name": "...", "displayName": "Till 4"}

Always send an update mask. Treat a mask-less update as a deliberate, reviewed decision.

Arrays and maps are replaced, not merged

An update mask covering a repeated field or a map replaces its whole contents. There is no “add one element” update. To add a label, read the map, add your entry locally, and send the complete map back — otherwise you delete every label you did not mention.

This is the mistake that most often shows up as “something removed all our labels”, usually from two jobs updating the same resource.

Making concurrent updates safe

For read-modify-write on anything contended, use compare-and-swap rather than hoping. Supply the state you believe the resource is in, plus a mask of the fields to verify; the server compares before writing and returns ABORTED if reality has moved on.

On ABORTED, re-read, re-apply your change, and try again. This is a correct retry loop, unlike blindly retrying a plain update, which would overwrite whatever the other writer did.

Upserts

allowMissing creates the resource if the name does not exist. Note that on the create path the update mask is ignored — the whole body is used. Send a complete resource when using it.

Trimming responses

An update response returns the resource. On a busy loop you can shrink it: suppress the body entirely, return only the fields actually changed, or supply a mask of the fields you want back. Worth doing for high-frequency writers.

Limits

RESOURCE_EXHAUSTED means a project limit has been reached — not that you are being rate-limited for calling too often. Retrying will not help; something has to change.

Limits are per resource type and per region, so a project spanning two regions has a separate allowance in each. Read your current usage and ceiling from the limits service, or in the dashboard under Settings → Limits, and request an increase there. Increases are approved, not granted instantly, so capacity is something to plan ahead of time.

For automation the practical consequence is to check headroom before a bulk create: a partially completed bulk operation is more work to reconcile than one that never started. See Understanding your limits.

Retrying well

Retry only the codes marked retryable above, and never a call whose effect you cannot reason about repeating.

  • Back off exponentially, with jitter. A fleet of clients retrying in step after an outage is its own second outage.
  • Cap total attempts, and surface a failure rather than retrying forever.
  • Make creates idempotent by supplying the identifier yourself. A create with a name you chose returns ALREADY_EXISTS on a duplicate attempt, which you can treat as success. A create with a server-generated name that times out leaves you unable to tell whether it succeeded — and may create a second resource when retried.
  • Be careful with DEADLINE_EXCEEDED on writes. The deadline expired on your side; the server may well have applied the change. Re-read before retrying, or use compare-and-swap.
  • Reconnect streams as a matter of course. For watch streams, dropped connections are routine, not exceptional.

Things that are not immediate

Several platform behaviors are applied by controllers after the write returns, which is correct but surprising if you assume read-after-write everywhere.

Change Becomes true
Role binding created or removed Within a minute or two
Device configuration change Next time the device polls
Application deployment When the device pulls and starts it
Limit increase request When approved

For anything in this table, poll for the observed state or watch for it. Do not assert it immediately after the write.

Next steps

7 - Deployment status

Drive a deployment from your own code and read back whether it worked.

Deploying from your own code is two APIs and one field. You write a Distribution to say what should run where, the platform creates a Pod per matching device, and you read status.phase on those pods to find out what happened.

This page is about the reading half. Creating resources follows the same patterns as everything else in this section; knowing when a rollout has actually succeeded is the part that catches people out.

Which resource to watch

Both live in the Applications service.

Resource What it is What you do with it
Distribution An application plus a rule for which devices should run it Create and update it to drive a rollout
Pod One running instance on one device Read it to find out what actually happened

A distribution records your intent. It does not tell you whether ninety devices took the update and ten failed. Status lives on the pods, so a rollout is judged by listing or watching the pods the distribution produced, not by re-reading the distribution.

The cuttle applications command family maps onto the same API, which makes it the fastest way to see the shape of a response before you write code against it.

Reading status.phase

Pod.Status.Phase is the single field that answers “did this work?”.

Phase Meaning
PENDING Accepted, not yet launched
RUNNING Every container that should be up is up
SUCCEEDED Terminated, with non-error exit codes
FAILED Ran, then some or all containers stopped running
UNKNOWN The device stopped responding
IMAGE_DOWNLOAD_FAILED The image pull failed
INIT_FAILED Validation or initialization failed — often compose syntax, or a full disk
POD_CREATE_FAILED The compose file is valid, but bringing the pod up failed
PHASE_UNSPECIFIED Unknown state; not normally used

Two things about this enum are worth internalizing before you write a polling loop against it.

There is no TERMINATED phase. A pod that has stopped is SUCCEEDED or FAILED, depending on exit codes. TERMINATED exists, but it is a container state, in status.containerStatuses[].state. Code that waits for a terminated pod by matching on the phase will wait forever.

Failure is not one value. Treating FAILED as “the deployment broke” misses three of the four ways it can break. IMAGE_DOWNLOAD_FAILED, INIT_FAILED, and POD_CREATE_FAILED all mean the deployment did not work, and none of them is FAILED — that phase is reserved for a pod that started successfully and then stopped. Match the whole failure set, or you will report success for a pod that never pulled its image.

When a pod is in any failure phase, status.error carries the message, and status.failureCount counts retries for the errors that are retried.

Health is a separate question

status.healthStatus is not a finer-grained status.phase. Phase answers whether the containers are running; health answers whether the containers that are running consider themselves well, aggregated across the pod. A pod is UNHEALTHY as soon as any one container is, and HEALTHY while at least one reports healthy.

So a pod can be RUNNING and UNHEALTHY at the same time, and for most alerting that combination is the interesting one — the platform believes it did its job, and your application disagrees.

Watch, do not poll

A rollout is exactly the case watch exists for: you want to know the moment a pod changes phase, across a fleet, without asking repeatedly.

Watch the pods your distribution produced and apply changes when isCurrent arrives, as described in Watching for changes. For a controller tracking a whole collection across reconnects, use a STATELESS watch so you can resume from a token rather than restarting with a fresh snapshot.

If you do poll — in a CI job that deploys and exits, say — request only the fields you need with a field mask. Pod status is much smaller than a full pod, and the difference is multiplied by every device in the fleet. See Reading resources.