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.
-
Get started — authenticate and make your first call, in
about ten minutes.
-
Authentication — service accounts, API keys, and how
to hold credentials safely in CI and in long-running services.
-
Resource names — how resources are addressed, why most
of them carry a region, and how to build a name you have never seen before.
-
Reading resources — the filter language, field masks and
views, ordering, and pagination. The largest page here, and the one that
pays for itself fastest.
-
Watching for changes — subscribe to a live stream of changes
instead of polling.
-
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.
-
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.
The REST API is generated and unsupported
The REST interface is derived mechanically from the gRPC service definitions
and is provided as a convenience. It is not supported, and its
specification is subject to change without notice. Use it for scripting and
exploration; build a product on 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.
The key is shown once
The platform does not retain the secret half of a key. The create response is
the only copy you will ever receive. If it is lost, delete the key and create
a replacement. See Authentication for how to hold it safely in CI and
in long-running services.
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.
Role bindings settle asynchronously
Access is applied by a platform controller, not at the moment the
binding is written, so a brand-new binding can take a minute or two to take
effect. A PERMISSION_DENIED immediately after granting access usually means
“not yet”, not “wrong”.
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
The key is shown once
The platform does not retain the secret half of a key. The create response is
the only copy you will ever receive. If it is lost, delete the key and create
a replacement.
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.
Some operators parse but do not run
The filter grammar is broader than the query engine behind it. OR (and its
| alias), NOT, NOT IN, LIKE, and IS NAN are all accepted by the
parser and then rejected when the query executes, as an UNIMPLEMENTED error.
The generated API reference and the cuttle --filter help text describe the
filter grammar, not what the query engine executes, so both list LIKE
and OR. The operator table above is the list that executes.
A filter is therefore not validated by “it parsed”. Compose with AND only,
and see Unions below for what to do when you genuinely need OR.
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.
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.
Search
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.
- Snapshot — the current state of your query, possibly split across
several responses.
- 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.
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.