Request callbacks¶
A request can name a callback URL. Every time a responder submits — or updates — a response, the server POSTs the submission to that URL. Callbacks are how a request feeds an external system (a CRM, a script, a spreadsheet importer) without anything polling the API.
Callbacks exist on requests only. Shares have no callback; their data is pulled, not pushed.
Besides submissions, a callback receives grant events: a note that an answer was revoked, changed or expired, without its values.
Configuring¶
Set the callback when creating or editing a request (the callbackUrl field,
under Limits & callback in the app). The URL is validated at save time, so a
bad target fails the save rather than failing silently at delivery:
- It must be an absolute
http://orhttps://URL. A bare127.0.0.1:8000is rejected as unparsable — the scheme and (ideally) a path are required:http://127.0.0.1:8000/hook. - Loopback, private-range and other internal addresses are refused by default. A user-supplied URL the server fetches is a server-side request forgery primitive; see Security for the policy and the opt-out.
A rejected URL surfaces as the error code validation_callback_url_blocked,
with the reason in the message.
Delivery¶
POST,Content-Type: application/json.- One extra header:
X-Revoked-Request: <requestId>— match it against the request id you configured the callback for. - Delivered from a background goroutine; the responder never waits on it.
- Bounded at 10 seconds total. Redirects are not followed — a redirect is treated as delivery to a different host and refused.
- At-most-once, no retries for submissions. Any transport error, timeout, or a response status ≥ 400 marks the delivery failed. Answer with a 2xx quickly and do your processing after responding. Grant events are retried; see below.
- Fires for new and updated responses alike. The payload does not carry a
new-vs-update flag; if the distinction matters, key on
responseId— an update re-delivers the same id.
Payload¶
{
"requestId": "lxt7nlkvfqairg5",
"slug": "vendor-onboarding",
"responseId": "9k2m4pqx81zvwy3",
"linkId": "9k2m4pqx81zvwy3",
"identity": "wt27a6nyfx93u3w",
"identifier": "customer-4711",
"senderName": "Ada Lovelace",
"data": {
"email": "ada@example.com",
"phone": "+44 20 7946 0000"
}
}
| Field | Meaning |
|---|---|
requestId |
The request this response answers. |
slug |
The request's public slug. |
responseId |
The response link's id. Stable across updates by the same responder. |
linkId |
Same value as responseId (legacy alias — responses are links). |
identity |
The responder's identity id, only if it was cryptographically proven by a handshake. A merely claimed identity is dropped, never delivered. Empty when the request did not require a handshake. |
identifier |
The identifier the responder entered, when the request requires one. |
senderName |
The free-text name the responder typed. Unverified — display data, not an identity. |
data |
Key → value map of the fields the responder typed directly. |
data does not contain vault-linked fields. When a responder links a
field to a record in their own vault, the response stores a living grant that
resolves at read time — the whole point is that the value is not copied
around. Those fields are absent from the callback payload; fetch the response
through the API (or the app) to see grants resolved to their current values.
Answers kept on another server¶
A responder whose account is on another Revoked server answers through that
server, and the answer stays in their vault there. The submission callback
then carries one more field, server, naming the responder's server, and
data holds the values as this server read them from there at the moment
of submission. identity is the identity's fingerprint rather than an id,
since the identity is registered on the other server.
Grant events¶
An answer is a living grant, so it can change after it was submitted: the responder edits the entry behind it, pauses or revokes it, deletes the entry, or the grant reaches its expiry. Each of these is delivered to the callback as an event:
{
"event": "grant.revoked",
"requestId": "lxt7nlkvfqairg5",
"slug": "vendor-onboarding",
"responseId": "9k2m4pqx81zvwy3",
"linkId": "9k2m4pqx81zvwy3",
"status": "revoked",
"at": "2026-10-02T09:14:03Z"
}
event |
Sent when |
|---|---|
grant.changed |
An entry the answer resolves changed value, directly or through an alias, or the answer was paused, resumed, or answered again with different entries. status says which state it is in now. |
grant.revoked |
The responder revoked the answer, deleted it, or deleted the last entry behind it. |
grant.expired |
The answer reached its expiry or view cap. |
An event never carries a value. When the current value matters, it is read
through GET /api/requests/{id}/links, which resolves every answer at the
moment of the call. For an answer kept on another server, the event also
carries server, and it reaches this server first as a signed event from
that server (POST /api/grants/events), which is checked against that
server's DNS-pinned root key before anything is passed on.
Unlike submissions, events are retried: a transport error, a timeout,
408, 429 or a 5xx is tried again after 30 seconds, then after doubling
intervals up to six hours, for about a day and a half in total. Any other
4xx ends the delivery at once. An event can therefore arrive more than
once, and late; responseId together with at identifies it, and an event
older than one already processed for the same responseId can be ignored.
A payload with event set is an event; a payload without it is a
submission.
Failures¶
A failed delivery emits an in-app notification (callback_failed) to the
request's owner, naming the request and the reason — a blocked URL, a timeout,
or the status your endpoint returned. A submission is not retried; after the
endpoint is fixed, the responder can re-submit, or the response can be read
through the API. A grant event raises the notification only once its retries
are used up.
Security¶
Two directions to think about.
What the server will deliver to (SSRF policy). The callback URL is attacker-influenced input this server fetches itself, so it goes through a hardened client: the URL is validated at save, redirects are refused, and the resolved IP is re-checked at connect time — validating only the hostname loses to DNS rebinding. Blocked by default: loopback, private ranges (10/8, 172.16/12, 192.168/16, fc00::/7), link-local (including the 169.254.169.254 cloud-metadata endpoint), multicast and 0.0.0.0.
For LAN-only installs and local development, set
ALLOW_PRIVATE_CALLBACKS=true
in the server's environment. That opens loopback and the private ranges. Link-local, multicast and 0.0.0.0 stay blocked regardless — no legitimate callback lives there on any deployment. Leave the flag off on anything public-facing.
What your endpoint should trust. Deliveries are currently not signed:
anyone who learns your callback URL can POST a payload of the same shape.
Treat the URL itself as a secret — include an unguessable path segment
(/hook/f3a9c1…), serve it over https, and check X-Revoked-Request
against the request id you expect. For anything security-relevant, verify the
data through the API rather than trusting the pushed payload; the identity
field is only as trustworthy as your confidence the payload really came from
your server.
Testing the URL¶
The request editor has a Send test payload button under the callback field. It posts one sample delivery and tells you what came back — a status, a refusal, or a timeout — so a wrong URL surfaces while you are still typing it rather than on the first real submission.
The send happens on the server, through the client a real delivery uses, so
the result answers the question that matters: can this host reach that URL
under the policy above. It is POST /api/requests/callback-test if you want it
from a script.
The sample has the shape of a real payload with obviously fake values, plus one extra field:
{ "...": "...", "test": true }
Branch on test so an example never lands in a CRM as a real answer. While the
request is unsaved the id and slug are placeholders; once it exists they are the
real ones, and X-Revoked-Request carries the real request id.
A failure comes back as a code, because all three otherwise read as "not
found" and each has a different fix:
code |
Means | Fix |
|---|---|---|
blocked |
The policy above refuses the address. | ALLOW_PRIVATE_CALLBACKS=true for a loopback or LAN hook. |
unreachable |
Nothing answered — refused, DNS, or timeout. | Check the host and port from the server's network, not yours. |
status |
The hook answered 4xx/5xx. | Its own logs. n8n answers 404 on a /webhook-test/ URL unless the editor is listening, and on a production URL unless the workflow is active. |
Testing locally¶
The repo root has a throwaway listener:
python run.py # listens on http://0.0.0.0:8000/hook
python run.py 9000 spy # http://0.0.0.0:9000/spy
It prints every delivery (headers, pretty-printed JSON body) and answers 404
with a hint for a wrong path. Point the request's callback at
http://127.0.0.1:8000/hook, run the server with
ALLOW_PRIVATE_CALLBACKS=true, submit a response to the request's public
link, and the payload appears in the terminal.
If the server runs in Docker, 127.0.0.1 is the container itself — use
http://host.docker.internal:8000/hook to reach a listener on the host.