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.

  1. Navigate to Actions > Rules and select a rule
  2. Open the Post-Events tab
  3. Configure the callback endpoint and lifecycle events

Endpoint Configuration

FieldDescription
URLThe endpoint AgentPress sends callbacks to
MethodHTTP method (POST, PUT, or PATCH)
HeadersCustom headers to include (for example, authorization tokens)
Custom FieldsAdditional key-value pairs merged into the callback payload
Lifecycle EventsWhich action lifecycle callbacks should be delivered to the endpoint
TimeoutRequest timeout in milliseconds

Lifecycle Event Types

Event TypeTypical StatusMeaning
action.pending_approvalstagedA tool call is waiting for approval. Payload includes stagedToolCall.
action.approvedapproved, executingThe action was approved or is running. This is not terminal success.
action.completedcompletedThe action finished successfully.
action.failedfailedThe action failed during processing or execution.
action.rejectedrejectedThe staged action was rejected.
action.expiredexpiredThe 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

FieldDefaultDescription
Max Attempts3Maximum delivery attempts
Initial Delay1sDelay before first retry
Backoff Multiplier2Exponential 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.

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 of express.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-signature header

Callback Payload

The ActionCallbackPayload type describes the shape of callback data your endpoint receives.

Fields

FieldTypeDescription
actionIdstringUnique action identifier
statusActionStatusCurrent action status
actionTypestringThe action type (for example, "review.created")
eventTypeActionEventTypePublic lifecycle event that triggered this callback
webhookActionstring | nullWebhook action identifier needed for SDK approve/reject calls
stagedToolCallStagedToolCall | nullTool call details for approval lifecycle callbacks when available
occurredAtstringISO 8601 timestamp for this lifecycle event
completedAtstringBackwards-compatible timestamp field; prefer occurredAt for new code
resultSummarystring | nullHuman-readable execution summary when populated, usually on terminal events
sourceDataRecord<string, unknown>Original data from the inbound webhook
externalIdstring | nullExternal system identifier used for idempotency
userIdstring | nullResolved AgentPress user ID
threadIdstring | nullThread ID if a conversation was created
agentResponseAgentResponseAgent text response and tool call results, most useful after terminal events
errorMessagestring | nullUser-safe error message if the action failed
errorDetails{ code, provider, recoverable, userMessage } | nullStructured metadata for branching/logging without raw provider errors
rejectionReasonstring | nullReason if rejected by a human reviewer

ActionStatus Values

StatusDescription
pendingAction created, awaiting processing
stagedAgent paused on a tool call awaiting approval
approvedTool call was approved and is queued to run
executingApproved tool call is currently running
rejectedStaged tool call was rejected
completedAction fully completed
failedAction processing or execution failed
expiredStaged 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.

ResponseBehavior
2xxDelivery successful, no retry
4xxPermanent failure, no retry (fix your endpoint)
5xxTemporary failure, retry with backoff
TimeoutRetry 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 actionId and eventType for traceability across your systems.
  • Switch on eventType for behavior, and use status for display state.
  • Treat approved and executing as in-flight states, not terminal success.
  • Treat resultSummary and stagedToolCall.summary as optional.

On this page