What it costs. Nothing. Subscriptions are not metered, and a delivery is not
a step.What you need. An API key with
devices:read, and an https endpoint
reachable from the public internet. Your laptop behind a private address will
be refused at subscribe time, not at delivery time; use a tunnel or a deployed
endpoint.There is no console screen for this. Webhooks are API only.Subscribe
201, and the response carries the signing secret once:
secretHint is its last four characters, which is there so you
can tell which secret you configured without holding the secret to compare.
An org may hold up to 20 subscriptions.
What the destination must be
The url is checked when you subscribe, and a refusal names which rule you hit:not_a_url, scheme_not_https, has_credentials, private_address,
too_long. It must be https, must carry no credentials in it, must be at most
2000 characters, and must not be a private, loopback or link local address.
That last one is not a style preference: this service would be the one making
the request, so an address inside a private network is an instruction to probe
somebody’s network from ours.
The four events
An unknown event name is refused rather than ignored, so a typo fails at
subscribe time instead of producing a subscription that never fires.
reason and detail on run.ended are present and null rather than absent
when there is nothing to say. A receiver branching on why a run ended needs to
tell “no reason” apart from “not told”.
The body
occurredAt is when the event happened, not when this attempt was made.
data is deliberately shallow and carries ids rather than nested records. A
receiver that needs the run reads GET /v1/runs/{agentRunId}, which is the one
definition of what a run is. Embedding a copy here would publish a second one
that drifts.
Three headers come with it:
Verify the signature
The hex is HMAC-SHA256 of<t>.<raw body> under your secret.
- Compute over the raw bytes you received, before any JSON parsing. A body
that has been through
JSON.parseand back is not the body that was signed. - Compare in constant time. A byte at a time comparison leaks the answer.
- Check
tagainst your own clock. The timestamp is inside the signed material precisely so that a captured delivery cannot be replayed at you a year later. The reference verifier uses a 300 second tolerance.
Delivery is at least once
Plan for a duplicate. Deduplicate onPhonebase-Delivery.
- A non 2xx answer is retried, 5 attempts in all, with gaps of 30 seconds, 2 minutes, 5 minutes and 18 minutes, so about twenty five minutes from first to last. Then it is given up on.
- Each attempt gets 10 seconds before it times out.
- Redirects are not followed. A
30xis recorded as the answer it is, because following one would send your payload to a url nobody checked. - Answer
2xxfast and do the work afterwards. An endpoint that does its processing before replying will be retried while it is still working.
Test it before you trust it
202 with { "deliveryId": "...", "status": "pending" }. It queues one
delivery on the same path a real event takes: same signature, same headers,
same retries. It answers as soon as the delivery is queued rather than waiting
for your endpoint, so read the outcome from the deliveries listing.
It arrives as run.ended with test: true in the data, because a test that
came in as something your router does not recognise would exercise the transport
and nothing else.
Read what happened
limit between 1 and 100 and 50 by default, paged with the
nextBefore cursor the response hands back.
Each row says what was tried and what came of it: status, attempts,
responseStatus, error, payloadPreview (the delivered body, truncated),
lastAttemptAt and nextAttemptAt.
Read status carefully. pending covers both never tried and waiting to retry.
failed means every attempt is spent, not that your endpoint said no.
responseStatus is null when the attempt never got one, and error then says
why: a timeout, a refused connection, a name that did not resolve.
payloadPreview is the fastest way to see the exact shape of an event you
have not handled before. Subscribe, fire one, read the preview.Turning one off, and rotating its secret
Disable rather than delete when you want to keep the history: a delivery log for
a subscription nobody can see is a log nobody can interpret, so deleting takes
it too.
Scope and tenancy
Every webhook route needsdevices:read and nothing else. There is no role
gate: a member with that scope can manage subscriptions.
A webhookId belonging to another org answers 404, the same 404 an id that
names nothing gets. This is not an existence oracle.
Where to go next
Runs
What
state, reason and detail on run.ended mean.