Device Activation API
REST API endpoints for the device activation flow. Allows agents and external systems to activate devices without interactive browser-based OAuth, using a code-based confirmation mechanism via the mobile app.
Device activation vs. agent permissions. This flow gets a key into an agent’s hands — the user pairs a device and an API key is issued, bound to a workspace. Narrowing what that key may do is a separate concern, covered in Agent Permissions: an agent can declare the scope set it actually needs and have the user approve exactly that, which is how an over-broad key gets tightened.
Overview
The device activation flow is designed for non-interactive environments (agents, CI/CD, headless systems):
- Agent initiates → Gets a code (expires in 10 minutes)
- User enters code in mobile app → App confirms the code
- Agent polls → Retrieves the issued API key (one-time read)
- Agent uses key → All future requests use the API key
Start Device Activation
Begin the device activation flow. This is a public endpoint requiring no authentication.
POST /api/v1/activate
Rate Limiting
This endpoint is rate-limited per client IP. Allow at least 1 request per second.
Example
curl -X POST https://clanker.net/api/v1/activate \
-H "Content-Type: application/json"
Response
{
"device_code": "8c47…opaque…",
"user_code": "ABCD-1234",
"verification_url": "https://clanker.net/activate",
"verification_url_complete": "https://clanker.net/activate?code=ABCD-1234",
"app_download_url": "https://clanker.net/app",
"expires_in": 600,
"interval": 2
}
Response Fields
| Field | Type | Description |
|---|---|---|
device_code | string | Opaque token the CLI uses for /poll. Never shown to humans. |
user_code | string | 8-character display code (format: XXXX-XXXX). Show this to the user. |
verification_url | string | URL where the user enters the code on the mobile app or web. |
verification_url_complete | string | Pre-filled approval URL (?code=…) for browser-based prefill. |
app_download_url | string | URL for downloading the Clanker mobile app. |
expires_in | number | Seconds until the code expires (600 = 10 minutes). |
interval | number | Minimum seconds between /poll calls. |
Poll Activation Status
Poll to check if the user has confirmed the activation code via the mobile app.
POST /api/v1/activate/poll
Public Endpoint
This endpoint requires no authentication. Rate-limit: up to once per second per code.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
device_code | string | Yes | The opaque device_code from the POST /api/v1/activate response — not the human user_code. |
Poll with the
device_code, the secret token the agent holds. Theuser_code(e.g.ABCD-1234) is only for the human to type into the app; polling with it returnsexpired.
Example
curl -X POST https://clanker.net/api/v1/activate/poll \
-H "Content-Type: application/json" \
-d '{"device_code": "9A98Btdj…opaque…"}'
Poll always answers 200 with a status. Only approved carries a key.
status | Meaning | What to do |
|---|---|---|
pending | The user has not answered yet | Keep polling |
approved | Approved, and this call is the one that got the key | Stop; store the key |
denied | The user rejected it in the app | Stop; do not retry |
consumed | Approved, but the key was already handed to an earlier poll | Stop; start a new activation |
expired | Past its 10-minute TTL, or the code was never valid | Stop; start a new activation |
Response (Pending)
{
"status": "pending"
}
Response (Completed)
User confirmed in the app. One-time read — whichever poll claims it first is the only one that ever sees the plaintext key.
{
"status": "approved",
"api_key": "ck_live_...",
"tier": "paygmode",
"workspace": { "id": "7c2e…", "name": "Acme" }
}
| Field | Type | Description |
|---|---|---|
status | string | "approved" — the row’s state. A "completed" alias existed for older CLIs and has been removed. |
api_key | string | The issued API key. Use this for all future requests. |
tier | string | User’s subscription mode: "byokmode", "paygmode", or "basedmode". |
workspace | object | The workspace the key is bound to. Absent when the key is not workspace-bound; name may be null. |
The key is bound to a workspace at issuance — whichever workspace the approving app was in — and cannot be repointed later. That is why the poll response tells you which one you got.
Response (Denied / Expired)
{
"status": "denied"
}
A denial is distinct from an expiry on purpose: the CLI can say “the user said no” instead of waiting out a timeout.
Polling Strategy
- Recommended interval: 2–5 seconds
- Timeout: Stop polling after 10 minutes (when code expires)
- Backoff: Optional exponential backoff to reduce load (max ~1 request/sec)
Confirm Activation (Mobile)
Confirm an activation code from the mobile app. This endpoint is called by the Clanker mobile app when the user enters a code in Settings → External Agents → Activate.
POST /api/v1/activate/confirm
Authentication
Requires authentication via x-auth-token (from mobile app session).
curl -X POST https://clanker.net/api/v1/activate/confirm \
-H "x-auth-token: YOUR_AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{"code": "ABCD-1234"}'
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
code | string | Yes | The activation code from the user input. |
Response
{
"success": true
}
Send x-workspace-id to bind the new key to a workspace other than your
personal one. You must be a member of it.
Errors
| Status | error.code | Description |
|---|---|---|
| 400 | INVALID_INPUT | Request body is missing the code field |
| 401 | UNAUTHORIZED | Missing or invalid x-auth-token header |
| 403 | FORBIDDEN | x-workspace-id names a workspace you are not a member of |
| 404 | NOT_FOUND | No such code |
| 409 | CONFLICT | Code is no longer pending — message names the state it is already in |
| 410 | EXPIRED | Code passed its 10-minute TTL |
| 500 | INTERNAL_ERROR | The API key could not be minted |
Approve and deny race through a single compare-and-set, so exactly one of them
wins and the loser gets the 409 describing the state that won.
Lookup Pending Code (Mobile)
Returns metadata about a pending code so the approval UI can show what the user is about to authorize (client label, origin IP, user agent, status). Used by the mobile app when the activation flow is initiated from a URL prefill (e.g. opening the CLI’s clanker://activate?code=… link).
GET /api/v1/activate/lookup?code=XXXX1234
Authentication: x-auth-token.
Response:
{
"user_code": "XXXX1234",
"client_label": "openclaw / dev-laptop",
"client_ua": "openclaw/0.4 (macOS 14.4)",
"client_ip": "203.0.113.42",
"status": "pending",
"expires_at": "2026-06-01T15:20:00.000Z"
}
status is one of pending | approved | denied | expired | consumed.
Deny Activation (Mobile)
Mark a pending activation as denied so the polling CLI receives a clean denial instead of an opaque expiration.
POST /api/v1/activate/deny
Authentication: x-auth-token. Body: { "code": "XXXX-1234" }. Response: { "success": true }.
End-to-End Flow Example
Step 1: Agent initiates activation
ACTIVATE_RESPONSE=$(curl -s -X POST https://clanker.net/api/v1/activate)
USER_CODE=$(echo $ACTIVATE_RESPONSE | jq -r '.user_code') # shown to the human
DEVICE_CODE=$(echo $ACTIVATE_RESPONSE | jq -r '.device_code') # used for polling
echo "Enter this code in the Clanker app: $USER_CODE"
Output:
Enter this code in the Clanker app: ABCD-1234
Step 2: User opens the Clanker mobile app
- Open the app
- Tap Account tab
- Tap Activate Device
- Enter the code:
ABCD-1234 - Tap Confirm
The app calls POST /api/v1/activate/confirm with the code.
Step 3: Agent polls for completion
while true; do
POLL_RESPONSE=$(curl -s -X POST https://clanker.net/api/v1/activate/poll \
-H "Content-Type: application/json" \
-d "{\"device_code\": \"$DEVICE_CODE\"}")
STATUS=$(echo $POLL_RESPONSE | jq -r '.status')
if [ "$STATUS" = "approved" ]; then
API_KEY=$(echo $POLL_RESPONSE | jq -r '.api_key')
echo "Activation complete! API Key: $API_KEY"
break
elif [ "$STATUS" = "denied" ]; then
echo "Denied in the app."
exit 1
elif [ "$STATUS" = "expired" ] || [ "$STATUS" = "consumed" ]; then
echo "Code is no longer usable. Try again."
exit 1
fi
sleep 2
done
Output:
Activation complete! API Key: ck_live_abc123def456...
Step 4: Agent uses the API key
All future requests use the x-api-key header:
curl https://clanker.net/api/v1/marketplace/skills \
-H "x-api-key: ck_live_abc123def456..."
Implementation Notes
Code Format
Activation codes are 8 characters in format XXXX-XXXX:
- Character set:
A-Z(no I/O to avoid confusion with numbers),2-9(no 0/1) - Randomness: Generated from cryptographic random bytes
- Case-insensitive: Normalized to uppercase by the server
Example codes: ABCD-1234, MXYZ-5678, WXYZ-9ABC
TTL (Time-To-Live)
| Resource | TTL | Notes |
|---|---|---|
| Activation code | 10 minutes (600 seconds) | expires_in in the create response |
| Key delivery entry | The code’s remaining lifetime | Written on approval, claimed by the first poll |
Approval does not extend the clock. A code approved at 9m30s leaves 30 seconds to collect the key.
State Machine
┌──(user denies)──────────────→ [Denied]
[Pending] ────────┤
│ └──(user confirms)──→ [Approved] ──(first poll claims key)──→ [Consumed]
│
└──(10 min elapses)──────────────────────────→ [Expired]
denied, consumed and expired are all terminal. Poll reports approved
for the single call that claims the key from the approved state; every later
poll on the same code sees consumed.
API Key Generation
When a code is confirmed:
- A new API key is generated with prefix
ck_live_, named after the device label and the user code - The key is bound to the approving workspace at creation
- The plaintext key is returned once in the poll response
- The claim is atomic, so a racing second poller never sees the plaintext
- Polling again with the same code returns
{ status: "consumed" }
Error Handling
Rate Limiting (429)
If you receive a 429 error on POST /api/v1/activate, wait before retrying.
Recommended: retry after 5 seconds with exponential backoff.
Invalid Request (400)
Missing required fields:
{
"error": {
"code": "INVALID_INPUT",
"message": "Missing activation code"
}
}
Every error on this surface uses that envelope. Branch on error.code — the
message is prose and may be reworded or translated.
Code Validation
The server normalizes codes (uppercase, trim) but validation is lenient:
# These all work:
{ "code": "ABCD-1234" }
{ "code": "abcd-1234" }
{ "code": " ABCD-1234 " }
Security Notes
- Codes are short-lived: 10-minute expiration prevents brute force
- One-time read: API key is deleted after first retrieval
- Rate-limited: Per-IP rate limiting on activation creation
- User-authenticated: Confirmation endpoint requires valid
x-auth-token - No PII in logs: Codes are not logged (only stored in ephemeral memory cache)
Troubleshooting
| Issue | Solution |
|---|---|
Poll returns expired | Create a new code with POST /api/v1/activate (10-minute timeout) |
Poll returns consumed | The key was already handed to an earlier poll — it is not re-issuable. Create a new code. |
Poll returns denied | The user rejected it. Ask them, then create a new code. |
Poll returns pending indefinitely | The user has not confirmed. Check mobile app notifications. |
Poll returns expired immediately | You are probably polling with the user_code. Poll with device_code. |
Confirm returns 409 | Another approve/deny already resolved the code; message names the winning state. |
| Rate limited | Wait 5+ seconds before creating another code. |