Webhooks
When a human approves or rejects an action in the Impri inbox, you can receive the decision in two ways: a webhook pushed to your server, or polling from your agent. Both are always available — they are not mutually exclusive.
Overview
Webhook: Impri makes a POST request to your callback_url when a decision is recorded. Useful when your agent has a public URL and you want near-instant notification.
Polling: Your agent calls GET /v1/actions/:id in a loop until status changes from pending. Always works — no public URL required. The MCP tool impri_await_decision implements this automatically.
The source of truth is always the database. If a webhook delivery fails, polling still returns the correct state.
Setting up a webhook
Pass callback_url when creating an action:
curl -X POST https://api.impri.dev/v1/actions \
-H "Authorization: Bearer $IMPRI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"kind": "email.send",
"title": "Outreach: partnership proposal",
"preview": { "format": "markdown", "body": "..." },
"callback_url": "https://your-agent.example.com/impri/webhook",
"expires_in": 86400
}'When the human makes a decision, Impri POSTs to https://your-agent.example.com/impri/webhook.
Restrictions on callback URLs
For security, callback_url must be an http:// or https:// URL. javascript: and data: schemes are rejected. By default the SSRF guard also blocks URLs that resolve to private IP ranges (RFC 1918, loopback, link-local) — this protects against an agent being manipulated into sending secrets to an internal service. On an isolated intranet deployment you can opt out by setting IMPRI_ALLOW_PRIVATE_TARGETS=1 in the server environment.
Webhook payload
Impri sends a POST with Content-Type: application/json. The body is always a JSON object:
{
"event": "action.updated",
"action_id": "act_abc123",
"status": "approved",
"decided_at": 1720000000,
"verdict": "approve",
"final_preview": {
"format": "markdown",
"body": "The advice conflicts because different advisors optimise..."
},
"diff": null
}| Field | Type | Present when |
|---|---|---|
event |
string | Always ("action.updated") |
action_id |
string | Always |
status |
string | Always (approved, rejected, expired) |
decided_at |
number (unix) | When a human decision was recorded |
verdict |
string | When a human decision was recorded ("approve" or "reject") |
final_preview |
object | When approved (carries human-edited content if editable fields were changed) |
diff |
string or null | When approved and the reviewer modified an editable field; a unified-style diff against the original |
When diff is present, final_preview contains the edited content. Always use final_preview.body for execution — never the original — because the reviewer may have changed it.
On rejection or expiry, final_preview and diff are absent. On expiry, verdict is absent; status is "expired".
Verifying the signature
Every webhook request carries three headers you should verify before processing:
| Header | Example | Description |
|---|---|---|
X-Impri-Signature |
sha256=a3f...9c1 |
HMAC-SHA256 of the raw request body |
X-Impri-Timestamp |
1720000000 |
Unix timestamp when the request was sent |
X-Impri-Nonce |
a1b2c3d4e5f6a7b8 |
Random hex string, unique per request |
The signature is computed over the concatenation ${timestamp}.${nonce}.${body} using the shared WEBHOOK_SECRET you set in the server environment.
Verification algorithm
expected = HMAC-SHA256(secret, "${timestamp}.${nonce}.${rawBody}")
signature = "sha256=" + hex(expected)Compare signature to X-Impri-Signature using a constant-time comparison to avoid timing attacks.
Python example:
import hashlib, hmac, time
def verify_impri_webhook(body: bytes, headers: dict, secret: str) -> bool:
timestamp = headers.get("X-Impri-Timestamp", "")
nonce = headers.get("X-Impri-Nonce", "")
received_sig = headers.get("X-Impri-Signature", "")
# Reject requests with a timestamp more than 5 minutes old
if abs(time.time() - int(timestamp)) > 300:
return False
payload = f"{timestamp}.{nonce}.".encode() + body
expected = "sha256=" + hmac.new(
secret.encode(), payload, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, received_sig)Node.js example:
import crypto from 'node:crypto';
function verifyImpriWebhook(rawBody, headers, secret) {
const timestamp = headers['x-impri-timestamp'];
const nonce = headers['x-impri-nonce'];
const receivedSig = headers['x-impri-signature'];
// Reject stale requests (>5 minutes clock skew)
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const payload = `${timestamp}.${nonce}.${rawBody}`;
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(receivedSig));
}Each project has its own webhook signing secret. Fetch it with GET /v1/project
(admin scope) as webhook_secret, and use that value in the verification above.
Rotate it any time with POST /v1/project/rotate-webhook-secret — update your
verifier with the new value afterward. The server-level WEBHOOK_SECRET env var
is only a fallback for actions created before a project secret existed.
The @impri/mcp package ships a ready-made verifier — import { verifyWebhookSignature } from '@impri/mcp/webhook' — so you don't have to hand-roll the HMAC check.
Retry schedule
If your endpoint does not return a 2xx response, Impri retries up to 5 times:
| Attempt | Delay after previous attempt |
|---|---|
| 1 (initial) | immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 25 minutes |
| 5 | 2 hours |
| 6 | 12 hours |
After the 6th attempt without a 2xx response, the delivery is moved to the dead-letter queue (DLQ). The action remains in its final state in the database and can still be retrieved by polling. You will see the delivery status ("not delivered to agent") in the web UI.
The retry scheduler runs every 60 seconds inside the server process.
Special response codes
Your webhook endpoint can return specific status codes to signal intent to Impri:
410 Gone: tells Impri the endpoint has been deregistered. Impri removes the callback_url from the action and stops all further delivery attempts. Use this to cleanly deregister a webhook when tearing down an agent.
Any 2xx: delivery confirmed. Impri marks the delivery as delivered.
Anything else (3xx, 4xx, 5xx, connection errors): delivery failed — Impri schedules a retry.
Polling as the always-on fallback
Polling does not require a public URL and is always available regardless of whether you provided a callback_url.
# Poll until status is no longer "pending"
while true; do
STATUS=$(curl -s https://api.impri.dev/v1/actions/$ACTION_ID \
-H "Authorization: Bearer $IMPRI_API_KEY" | jq -r .status)
[ "$STATUS" = "pending" ] || break
sleep 10
doneThe MCP tool impri_await_decision does this internally, polling every 5 seconds with a configurable timeout (default 300 seconds).
For list-based polling (e.g. an agent that processes all newly-approved actions on a schedule):
curl "https://api.impri.dev/v1/actions?status=approved&since=1719990000" \
-H "Authorization: Bearer $IMPRI_API_KEY"since is a Unix timestamp. The response is paginated with has_more and next_cursor for large result sets.
Delivery status in the UI
The web inbox shows the webhook delivery status on each action card: delivered, retry (attempt N/6), dlq, or gone. This makes it visible when an agent is not receiving decisions, without requiring you to check server logs.