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.
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. Any transport error, timeout, or a response status ≥ 400 marks the delivery failed. Answer with a 2xx quickly and do your processing after responding.
- 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 for guests. |
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.
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. There is no automatic retry; fix the
endpoint and ask the responder to re-submit, or read the response through the
API.
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 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.