Receiving Callbacks
AgentPress can send signed HTTP callbacks as an action moves through its lifecycle. Use these callbacks to show pending approvals, react when an approved action starts running, and handle terminal outcomes after execution finishes.
Callbacks use the same Svix HMAC-SHA256 signing scheme as inbound webhooks, so your server can verify their authenticity using the @agentpress/sdk.
Setting Up Post-Events
Post-event callbacks are configured per action rule in the admin console.
- Navigate to Actions > Rules and select a rule
- Open the Post-Events tab
- Configure the callback endpoint and lifecycle events
Endpoint Configuration
| Field | Description |
|---|---|
| URL | The endpoint AgentPress sends callbacks to |
| Method | HTTP method (POST, PUT, or PATCH) |
| Headers | Custom headers to include (for example, authorization tokens) |
| Custom Fields | Additional key-value pairs merged into the callback payload |
| Lifecycle Events | Which action lifecycle callbacks should be delivered to the endpoint |
| Timeout | Request timeout in milliseconds |
Lifecycle Event Types
| Event Type | Typical Status | Meaning |
|---|---|---|
action.pending_approval | staged | A tool call is waiting for approval. Payload includes stagedToolCall. |
action.approved | approved, executing | The action was approved or is running. This is not terminal success. |
action.completed | completed | The action finished successfully. |
action.failed | failed | The action failed during processing or execution. |
action.rejected | rejected | The staged action was rejected. |
action.expired | expired | The staged action expired before approval. |
There is no separate public action.executing event type. If the internal action status is executing, the callback uses eventType: "action.approved" with status: "executing".
Retry Configuration
| Field | Default | Description |
|---|---|---|
| Max Attempts | 3 | Maximum delivery attempts |
| Initial Delay | 1s | Delay before first retry |
| Backoff Multiplier | 2 | Exponential backoff factor |
Verifying Callbacks
All callbacks include Svix signature headers (svix-id, svix-timestamp, svix-signature). You should always verify the signature before processing the payload.
Using constructEvent (Recommended)
constructEvent verifies the signature and parses the payload into a typed ActionCallbackPayload in a single step. This is the recommended approach.
Express:
import express from "express";
import {
AgentPress,
WebhookSignatureError,
type ActionCallbackPayload,
} from "@agentpress/sdk";
const app = express();
const client = new AgentPress({
webhookSecret: process.env.AGENTPRESS_WEBHOOK_SECRET,
});
app.post(
"/webhooks/agentpress",
express.raw({ type: "application/json" }),
(req, res) => {
let event: ActionCallbackPayload;
try {
event = client.webhooks.constructEvent({
payload: req.body,
headers: {
"svix-id": req.headers["svix-id"] as string,
"svix-timestamp": req.headers["svix-timestamp"] as string,
"svix-signature": req.headers["svix-signature"] as string,
},
});
} catch (error) {
if (error instanceof WebhookSignatureError) {
return res.status(401).json({ error: "Invalid signature" });
}
throw error;
}
console.log(`Action ${event.actionId} emitted ${event.eventType}`);
res.json({ received: true });
},
);Important: The request body must be the raw string or Buffer, not parsed JSON. Using
express.json()instead ofexpress.raw()will cause signature verification to fail because the re-serialized body may differ from the original signed payload.
Hono:
import { Hono } from "hono";
import { AgentPress, WebhookSignatureError } from "@agentpress/sdk";
const app = new Hono();
const client = new AgentPress({
webhookSecret: process.env.AGENTPRESS_WEBHOOK_SECRET,
});
app.post("/webhooks/agentpress", async (c) => {
const rawBody = await c.req.text();
try {
const event = client.webhooks.constructEvent({
payload: rawBody,
headers: {
"svix-id": c.req.header("svix-id")!,
"svix-timestamp": c.req.header("svix-timestamp")!,
"svix-signature": c.req.header("svix-signature")!,
},
});
console.log(`Action ${event.actionId} emitted ${event.eventType}`);
return c.json({ received: true });
} catch (error) {
if (error instanceof WebhookSignatureError) {
return c.json({ error: "Invalid signature" }, 401);
}
throw error;
}
});Manual Verification
If you need to separate signature verification from payload parsing, the SDK provides two lower-level methods.
verify returns a boolean:
const isValid = client.webhooks.verify({
payload: rawBody,
headers: {
"svix-id": headers["svix-id"],
"svix-timestamp": headers["svix-timestamp"],
"svix-signature": headers["svix-signature"],
},
});
if (!isValid) {
return new Response("Unauthorized", { status: 401 });
}
const event = JSON.parse(rawBody) as ActionCallbackPayload;verifyOrThrow throws WebhookSignatureError on failure:
try {
client.webhooks.verifyOrThrow({
payload: rawBody,
headers: {
"svix-id": headers["svix-id"],
"svix-timestamp": headers["svix-timestamp"],
"svix-signature": headers["svix-signature"],
},
});
} catch (error) {
if (error instanceof WebhookSignatureError) {
return new Response("Invalid signature", { status: 401 });
}
}
const event = JSON.parse(rawBody) as ActionCallbackPayload;Verification Details
- Timestamp must be within 5 minutes of current time (replay protection)
- Uses timing-safe comparison to prevent timing attacks
- Supports multiple space-separated signatures in the
svix-signatureheader
Callback Payload
The ActionCallbackPayload type describes the shape of callback data your endpoint receives.
Fields
| Field | Type | Description |
|---|---|---|
actionId | string | Unique action identifier |
status | ActionStatus | Current action status |
actionType | string | The action type (for example, "review.created") |
eventType | ActionEventType | Public lifecycle event that triggered this callback |
webhookAction | string | null | Webhook action identifier needed for SDK approve/reject calls |
stagedToolCall | StagedToolCall | null | Tool call details for approval lifecycle callbacks when available |
occurredAt | string | ISO 8601 timestamp for this lifecycle event |
completedAt | string | Backwards-compatible timestamp field; prefer occurredAt for new code |
resultSummary | string | null | Human-readable execution summary when populated, usually on terminal events |
sourceData | Record<string, unknown> | Original data from the inbound webhook |
externalId | string | null | External system identifier used for idempotency |
userId | string | null | Resolved AgentPress user ID |
threadId | string | null | Thread ID if a conversation was created |
agentResponse | AgentResponse | Agent text response and tool call results, most useful after terminal events |
errorMessage | string | null | User-safe error message if the action failed |
errorDetails | { code, provider, recoverable, userMessage } | null | Structured metadata for branching/logging without raw provider errors |
rejectionReason | string | null | Reason if rejected by a human reviewer |
ActionStatus Values
| Status | Description |
|---|---|
pending | Action created, awaiting processing |
staged | Agent paused on a tool call awaiting approval |
approved | Tool call was approved and is queued to run |
executing | Approved tool call is currently running |
rejected | Staged tool call was rejected |
completed | Action fully completed |
failed | Action processing or execution failed |
expired | Staged action expired before approval |
Treat approved and executing as in-flight states. Do not mark external work as successful until you receive action.completed.
StagedToolCall Structure
interface StagedToolCall {
toolName: string;
toolCallId: string;
arguments: Record<string, unknown>;
summary?: string | StagedToolCallSummary;
}
interface StagedToolCallSummary {
title: string;
detail: string;
}New approval callbacks use the structured summary shape. Older stored actions may have a plain string summary, and summaries can be absent.
AgentResponse Structure
interface AgentResponse {
text: string | null;
toolCalls: ToolCallResult[];
}
interface ToolCallResult {
toolName: string;
arguments: Record<string, unknown>;
result: unknown;
}Handling Lifecycle Events
Switch on event.eventType for application behavior. Use event.status as additional state for rendering, especially to distinguish approved from executing.
switch (event.eventType) {
case "action.pending_approval":
console.log("Approval needed for", event.stagedToolCall?.toolName);
break;
case "action.approved":
console.log(`Action ${event.actionId} is ${event.status}`);
break;
case "action.completed":
console.log(event.resultSummary ?? event.agentResponse.text);
break;
case "action.failed":
console.error(event.errorMessage ?? "Action failed");
break;
case "action.rejected":
console.log(event.rejectionReason ?? "Action rejected");
break;
case "action.expired":
console.log("Approval expired");
break;
}Managing Approvals with the SDK
Use the webhookAction from the callback when approving or rejecting. The approve response can return approved or executing; that response only confirms the approval request was accepted.
await client.actions.approve(event.actionId, {
action: event.webhookAction!,
remember: "once",
});To remember the approval for future triggers from the same user, webhook, and tool combination:
await client.actions.approve(event.actionId, {
action: event.webhookAction!,
remember: "webhook",
});To edit arguments before execution, send the full tool call you want AgentPress to run:
await client.actions.approve(event.actionId, {
action: event.webhookAction!,
editedToolCall: {
toolName: event.stagedToolCall!.toolName,
arguments: {
...event.stagedToolCall!.arguments,
subject: "Updated subject",
},
},
});Reject with an optional reason:
await client.actions.reject(event.actionId, {
action: event.webhookAction!,
reason: "Needs more context",
});Example Callbacks
Pending Approval
{
"actionId": "550e8400-e29b-41d4-a716-446655440000",
"status": "staged",
"actionType": "review.created",
"eventType": "action.pending_approval",
"webhookAction": "review_response",
"stagedToolCall": {
"toolName": "postReviewResponse",
"toolCallId": "toolu_123",
"arguments": {
"reviewId": "review-12345",
"response": "Thank you for your review."
},
"summary": {
"title": "Post review response",
"detail": "Post a response to Jane Smith's 5-star Downtown Location review."
}
},
"occurredAt": "2026-05-06T18:29:00.000Z",
"completedAt": "2026-05-06T18:29:00.000Z",
"resultSummary": null,
"sourceData": {
"reviewText": "Great service!",
"rating": 5
},
"externalId": "review-12345",
"userId": "user-uuid-here",
"threadId": "thread-uuid-here",
"agentResponse": {
"text": null,
"toolCalls": []
},
"errorMessage": null,
"rejectionReason": null
}Completed
{
"actionId": "550e8400-e29b-41d4-a716-446655440000",
"status": "completed",
"actionType": "review.created",
"eventType": "action.completed",
"webhookAction": "review_response",
"stagedToolCall": null,
"occurredAt": "2026-05-06T18:30:00.000Z",
"completedAt": "2026-05-06T18:30:00.000Z",
"resultSummary": "Posted a response to Jane Smith's 5-star Downtown Location review.",
"sourceData": {
"reviewText": "Great service!",
"rating": 5,
"locationName": "Downtown Location",
"reviewerName": "Jane Smith"
},
"externalId": "review-12345",
"userId": "user-uuid-here",
"threadId": "thread-uuid-here",
"agentResponse": {
"text": "Thank you for your wonderful review, Jane! We're thrilled to hear about your experience at our Downtown Location.",
"toolCalls": [
{
"toolName": "postReviewResponse",
"arguments": { "reviewId": "review-12345", "response": "..." },
"result": { "success": true }
}
]
},
"errorMessage": null,
"rejectionReason": null
}Retry Behavior
When a callback delivery fails, AgentPress retries with exponential backoff based on your post-event configuration.
| Response | Behavior |
|---|---|
| 2xx | Delivery successful, no retry |
| 4xx | Permanent failure, no retry (fix your endpoint) |
| 5xx | Temporary failure, retry with backoff |
| Timeout | Retry with backoff |
After exhausting all retry attempts, the callback is marked as failed. You can view delivery status in the Actions console.
AgentPress deduplicates configured post-event delivery queueing by action, post-event, and lifecycle event type. Your receiving endpoint should still be idempotent because HTTP retries can re-send the same callback attempt.
Full Example
A complete Express server that receives and processes action lifecycle callbacks:
import express from "express";
import {
AgentPress,
WebhookSignatureError,
type ActionCallbackPayload,
} from "@agentpress/sdk";
const app = express();
const client = new AgentPress({
webhookSecret: process.env.AGENTPRESS_WEBHOOK_SECRET,
});
function formatApprovalSummary(event: ActionCallbackPayload) {
const summary = event.stagedToolCall?.summary;
if (!summary) return null;
if (typeof summary === "string") return summary;
return summary.detail || summary.title;
}
app.post(
"/webhooks/agentpress",
express.raw({ type: "application/json" }),
(req, res) => {
let event: ActionCallbackPayload;
try {
event = client.webhooks.constructEvent({
payload: req.body,
headers: {
"svix-id": req.headers["svix-id"] as string,
"svix-timestamp": req.headers["svix-timestamp"] as string,
"svix-signature": req.headers["svix-signature"] as string,
},
});
} catch (error) {
if (error instanceof WebhookSignatureError) {
console.error("Invalid webhook signature");
return res.status(401).json({ error: "Invalid signature" });
}
console.error("Webhook processing error:", error);
return res.status(400).json({ error: "Invalid payload" });
}
switch (event.eventType) {
case "action.pending_approval":
console.log(`Approval needed: ${formatApprovalSummary(event)}`);
break;
case "action.approved":
console.log(`Action ${event.actionId} is ${event.status}`);
break;
case "action.completed":
console.log(`Action ${event.actionId} completed`);
console.log(event.resultSummary ?? event.agentResponse.text);
break;
case "action.failed":
console.error(`Action ${event.actionId} failed: ${event.errorMessage}`);
break;
case "action.rejected":
console.log(
`Action ${event.actionId} rejected: ${event.rejectionReason}`,
);
break;
case "action.expired":
console.log(`Action ${event.actionId} expired before approval`);
break;
}
res.json({ received: true });
},
);
app.listen(3000, () => {
console.log("Webhook server listening on port 3000");
});Key points:
- Always return a 2xx response promptly to acknowledge receipt. Slow responses may trigger retries.
- Process callbacks asynchronously if your handler involves heavy computation.
- Log the
actionIdandeventTypefor traceability across your systems. - Switch on
eventTypefor behavior, and usestatusfor display state. - Treat
approvedandexecutingas in-flight states, not terminal success. - Treat
resultSummaryandstagedToolCall.summaryas optional.