Provisioning Key Webhook
A provisioning key in webhook mode asks your service whether to admit each device that enrolls with it. This page is the contract: what ShellHub sends, what it accepts back, and how to answer later instead of immediately.
The request
ShellHub sends a POST with a JSON body and two headers.
| Header | Value |
|---|---|
Content-Type |
application/json |
X-ShellHub-Signature |
HMAC-SHA256 of the raw body, hex encoded, under the key’s webhook secret |
{
"tenant_id": "00000000-0000-4000-0000-000000000000",
"provisioning_key_id": "b1946ac92492d2347c6235b4d2611184",
"provisioning_key_name": "factory-line-2",
"device_uid": "3c9e2f1a7b4d8e6f0a1c5d9b2e7f4a83",
"identity": "00:1b:44:11:3a:b7",
"hostname": "gateway-01",
"info": { "id": "debian", "pretty_name": "Debian GNU/Linux 12", "arch": "arm64" },
"source_ip": "203.0.113.9",
"timestamp": "2026-08-26T03:41:00Z",
"callback_url": "https://cloud.shellhub.io/api/devices/enroll/callback/<token>"
}
The secret itself is never sent, not even a fragment. Authenticity comes from the signature: compute the same HMAC over the raw body you received and compare.
callback_url is absent when the server cannot work out its own public address.
The answer
Reply 2xx with a JSON body:
{ "decision": "accept", "reason": "serial in manufacturing batch 44" }
decision |
Result |
|---|---|
accept |
The device is accepted into the namespace |
reject |
The device is rejected |
defer |
Decide later, through the callback URL |
pending |
Leave it for a human in the console |
reason is optional and is recorded with the decision.
Anything else fails closed to pending: a transport error, a non-2xx status, a body that does
not parse, an unrecognized decision, or no answer inside the timeout. The device waits for a
human rather than walking in.
The request times out in 5 seconds by default, 15 at most. Answer defer rather than
holding the connection open.
Deciding later
POST to the callback_url from the request:
$ curl -X POST "$CALLBACK_URL" \
-H 'Content-Type: application/json' \
-d '{"decision":"accept"}'
Only accept and reject are valid here — defer has already happened.
The URL carries a signed, expiring token scoped to that one device. It is valid for 1 hour by default, 24 hours at most, and it is single use: a second POST with the same URL is refused, so a replay cannot flip a decision.
An accept arriving through the callback is checked again on the way in. The key is re-resolved
and revalidated, because it may have been revoked, disabled, expired or exhausted since the token
was minted, and a use is reserved against its limit. A deferred accept is bound by the same rules
as an immediate one.
A device left pending this way is re-evaluated the next time the agent authenticates, so a decision that was deferred resolves on its own once your service answers.
Reachability
The webhook must be reachable at a public address. The destination is checked after DNS resolution, so a hostname that resolves into a private network is refused too.
Where to go next
- Provisioning Keys — The modes, and everything a key carries.
- API Authentication — The credentials the rest of the API takes.