Docs · API reference
Devices API
A credential for hardware you cannot lock in a rack: a till or tablet pairs itself with a typed code, then holds a short-lived token of its own that is narrower than any API key and revocable on its own.
Why not just use an API key?
An API key belongs on a server. A tablet on a bar counter is not a server — it is hardware in a public room that can be picked up — and a key on it would carry your whole account: every template, every session anyone has ever run, and the authority to erase them.
A device credential is the alternative. It is issued per tablet, holds a fixed and deliberately small set of powers, expires in minutes rather than never, and can be killed on its own without rotating anything else.
| API key | Device | |
|---|---|---|
| Lives | Until revoked or expired | 15 minutes, refreshed |
| Templates | Every template you have | Only those named at registration |
| Inline DCQL | Allowed | Refused |
| Sees | Every session in the account | Only the sessions it created |
| Result detail | Claims and issuer-signed credentials | Claims only |
| Erasure | With verifications:delete | Never |
Pairing a device
Three steps, and only the first touches the back office.
POST/v1/devicesdevices:manage
curl -X POST https://api.valitel.eu/v1/devices \
-H "authorization: Bearer $VALITEL_API_KEY" \
-H "content-type: application/json" \
-d '{"name":"Bar 2 till","allowed_templates":["age_over_18"]}'| Field | Type | Notes |
|---|---|---|
name | string, 1–64 chars | The label you will recognise on the device list, and the actor name in your audit trail. |
allowed_templates | array, 1–20 keys | The only templates this device may verify with. Non-empty: a device with no template could do nothing. |
pairing_ttl_seconds | integer, 60–86400 | How long the pairing code stays claimable. Default 900 — raise it for a tablet you ship before anyone pairs it. |
{
"device": { "id": "0f2b6d1c-…", "status": "pending", "…": "…" },
"pairing_code": "VD-7K4M-QP2X"
}Someone then types the code into the tablet, which claims it. This call carries no
Authorization header, because the code is the credential:
POST/v1/devices/enrollno auth
curl -X POST https://api.valitel.eu/v1/devices/enroll \
-H "content-type: application/json" \
-d '{"pairing_code":"vd7k4mqp2x"}'{
"access_token": "vd_qN2xhVX0mA7Kd1jsPu4RbTfZ8cwLyE6g",
"refresh_token": "vdr_5tBm9zKqW3nHxRc7Yv1UpLdA2fJs4Geo",
"token_type": "Bearer",
"expires_in": 900,
"refresh_expires_in": 604800,
"device": {
"id": "0f2b6d1c-8a44-4d0e-9f61-3c2e5b7a1d90",
"name": "Bar 2 till",
"status": "active",
"allowed_templates": ["age_over_18"],
"created_at": "2026-08-21T18:02:11.004Z",
"enrolled_at": "2026-08-21T18:04:37.512Z",
"last_seen_at": "2026-08-21T18:04:37.512Z"
}
}Lowercase and missing dashes are forgiving — a human is retyping this off a screen.
A misread character is not: the alphabet excludes 0 1 I L O U V so that
confusing 0 with O produces a code that cannot exist rather
than somebody else's.
Errors400 invalid_pairing_code 429 rate_limit_exceeded 500 internal_error
Pair a till with the free Valitel staff app
You do not have to build the tablet side yourself. The Valitel staff app for iOS
and Android is free for Valitel customers and makes the device-side calls on this page
for you: it claims a pairing code through POST /v1/devices/enroll, keeps its
tokens fresh under the rules in Refreshing, and runs one check —
the Age 18+ preset.
- Register the till with
age_over_18among its allowed templates, in either of two ways:- On the dashboard's Devices page, no API key needed: an
admin or owner names the till, ticks
age_over_18and chooses Create pairing code. The dashboard asks for the password again first, and shows the code once. - From your own backend, with
POST /v1/devicesas above, using an API key minted withdevices:manageon the dashboard's API keys page. The scope is never pre-ticked, so a key minted with the defaults cannot register a device.
- On the dashboard's Devices page, no API key needed: an
admin or owner names the till, ticks
- Open the staff app on the till and type the pairing code while it is still claimable — 15 minutes by default.
Search “Valitel” in the App Store or Google Play once a listing is live — the
direct links are on the product page's staff app section for anyone
browsing signed out. A signed-in reader of these docs will not see that section on
/: it renders your live verification log instead.
Verifying from the device
From here the tablet is an ordinary API client: put its
access_token in the same bearer header and call
the verifications API as normal. The wallet cannot
tell the difference, because there is none — a device's session is an ordinary
session.
What differs is what the device may ask for, and what it gets back:
| Attempt | Result |
|---|---|
A template outside allowed_templates | 403 template_not_allowed |
An inline query | 403 raw_query_not_allowed |
| Reading a session it did not create | 404 |
| Listing devices, or erasing a verification | 403 |
An allowlist over template keys would be worth nothing if the same request could be inlined as raw DCQL, which is why the second row exists.
A completed result read by a device carries claims and no
credentials array:
{
"id": "vrf_G3mgaU2NnqZU-vNBd_jFAw",
"status": "completed",
"claims": { "age_over_18": true }
}The till gets the decision, not the issuer-signed evidence: the per-credential block carries issuer identity, trust findings and proof metadata — a compliance surface for your backend, and pure excess disclosure on a screen in a bar. Read the same session with your API key and it is all there. A device seeing less is not data loss; it is the point.
Refreshing, and the one way to brick a till
POST/v1/devices/tokenno auth
Access tokens last 15 minutes. The refresh token lasts a week and is single-use — every exchange returns a whole new pair.
curl -X POST https://api.valitel.eu/v1/devices/token \
-H "content-type: application/json" \
-d '{"refresh_token":"vdr_…"}'Two rules follow, and they belong in your client rather than your runbook:
- Persist the new pair before discarding the old one. A crash between the two loses the device.
- Never blindly retry a request to this endpoint that timed out. You do not know whether the server rotated; retrying with the same token is exactly the replay that kills it. Treat an unseen response as "possibly rotated".
The previous access token is deliberately left alive until it expires on its own, so a device that refreshes early does not kill a request still in flight.
On a 401, branch on the code:
device_token_expired means refresh and retry once; anything else means
the credential is gone and the tablet needs re-pairing.
Errors400 invalid_refresh_token 429 rate_limit_exceeded 500 internal_error
Listing and revoking
GET/v1/devicesdevices:manage
Every device you have registered, { "data": [ … ] }, pending and
revoked included. A device token gets 403 here — a tablet cannot
enumerate its siblings.
DELETE/v1/devices/:iddevices:manage
Revokes a device. Its live access and refresh tokens stop working immediately — including a refresh it had already queued — and an unclaimed pairing code is destroyed with them. Nothing it verified is affected.
Returns 200 with the device row, and does so again on a second call, so
a lost response is safe to retry. Revocation is terminal: a device that needs to come
back is a new registration and a new pairing code.
Errors401 invalid_api_key 403 insufficient_scope 404 device_not_found 429 rate_limit_exceeded
Rate limits for a fleet
Two budgets apply, and neither is your API key's — see Rate limits for the general shape.
- Per device. Each device gets its own bucket, so one busy till cannot spend your backend's allowance or the allowance of the till beside it.
- Per address, shared. Enrolment and refresh share a tighter per-IP budget of their own — 20 a minute by default — because they are exactly what a brute force against the pairing-code alphabet would target.