fix(hooks): return 200 instead of 202 for webhook responses (#28204)

* fix(hooks): return 200 instead of 202 for webhook responses (#22036)

* docs(webhook): document 200 status for hooks agent

* chore(changelog): add webhook ack note openclaw#28204 thanks @Glucksberg

---------

Co-authored-by: Shakker <shakkerdroid@gmail.com>
This commit is contained in:
Glucksberg
2026-03-02 20:19:31 -04:00
committed by GitHub
parent dee7cda1ec
commit 051b380d38
4 changed files with 29 additions and 14 deletions

View File

@@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai
- Gateway/Control UI basePath webhook passthrough: let non-read methods under configured `controlUiBasePath` fall through to plugin routes (instead of returning Control UI 405), restoring webhook handlers behind basePath mounts. (#32311) Thanks @ademczuk.
- Voice-call/Twilio signature verification: retry signature validation across deterministic URL port variants (with/without port) to handle mixed Twilio signing behavior behind reverse proxies and non-standard ports. (#25140) Thanks @drvoss.
- Hooks/webhook ACK compatibility: return `200` (instead of `202`) for successful `/hooks/agent` requests so providers that require `200` (for example Forward Email) accept dispatched agent hook deliveries. (#28204) Thanks @Glucksberg.
- Voice-call/Twilio external outbound: auto-register webhook-first `outbound-api` calls (initiated outside OpenClaw) so media streams are accepted and call direction metadata stays accurate. (#31181) Thanks @scoootscooob.
- Voice-call/Twilio inbound greeting: run answered-call initial notify greeting for Twilio instead of skipping the manager speak path, with regression coverage for both Twilio and Plivo notify flows. (#29121) Thanks @xinhuagu.
- Voice-call/stale call hydration: verify active calls with the provider before loading persisted in-progress calls so stale locally persisted records do not block or misroute new call handling after restarts. (#4325) Thanks @garnetlyx.

View File

@@ -159,7 +159,7 @@ Mapping options (summary):
## Responses
- `200` for `/hooks/wake`
- `202` for `/hooks/agent` (async run started)
- `200` for `/hooks/agent` (async run accepted)
- `401` on auth failure
- `429` after repeated auth failures from the same client (check `Retry-After`)
- `400` on invalid payload

View File

@@ -358,7 +358,7 @@ export function createHooksRequestHandler(
}),
agentId: targetAgentId,
});
sendJson(res, 202, { ok: true, runId });
sendJson(res, 200, { ok: true, runId });
return true;
}
@@ -424,7 +424,7 @@ export function createHooksRequestHandler(
timeoutSeconds: mapped.action.timeoutSeconds,
allowUnsafeExternalContent: mapped.action.allowUnsafeExternalContent,
});
sendJson(res, 202, { ok: true, runId });
sendJson(res, 200, { ok: true, runId });
return true;
}
} catch (err) {

View File

@@ -72,7 +72,7 @@ describe("gateway server hooks", () => {
mockIsolatedRunOkOnce();
const resAgent = await postHook(port, "/hooks/agent", { message: "Do it", name: "Email" });
expect(resAgent.status).toBe(202);
expect(resAgent.status).toBe(200);
const agentEvents = await waitForSystemEvent();
expect(agentEvents.some((e) => e.includes("Hook Email: done"))).toBe(true);
drainSystemEvents(resolveMainKey());
@@ -83,7 +83,7 @@ describe("gateway server hooks", () => {
name: "Email",
model: "openai/gpt-4.1-mini",
});
expect(resAgentModel.status).toBe(202);
expect(resAgentModel.status).toBe(200);
await waitForSystemEvent();
const call = (cronIsolatedRun.mock.calls[0] as unknown[] | undefined)?.[0] as {
job?: { payload?: { model?: string } };
@@ -97,7 +97,7 @@ describe("gateway server hooks", () => {
name: "Email",
agentId: "hooks",
});
expect(resAgentWithId.status).toBe(202);
expect(resAgentWithId.status).toBe(200);
await waitForSystemEvent();
const routedCall = (cronIsolatedRun.mock.calls[0] as unknown[] | undefined)?.[0] as {
job?: { agentId?: string };
@@ -111,7 +111,7 @@ describe("gateway server hooks", () => {
name: "Email",
agentId: "missing-agent",
});
expect(resAgentUnknown.status).toBe(202);
expect(resAgentUnknown.status).toBe(200);
await waitForSystemEvent();
const fallbackCall = (cronIsolatedRun.mock.calls[0] as unknown[] | undefined)?.[0] as {
job?: { agentId?: string };
@@ -201,8 +201,15 @@ describe("gateway server hooks", () => {
cronIsolatedRun.mockClear();
cronIsolatedRun.mockResolvedValue({ status: "ok", summary: "done" });
const defaultRoute = await postHook(port, "/hooks/agent", { message: "No key" });
expect(defaultRoute.status).toBe(202);
const defaultRoute = await fetch(`http://127.0.0.1:${port}/hooks/agent`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer hook-secret",
},
body: JSON.stringify({ message: "No key" }),
});
expect(defaultRoute.status).toBe(200);
await waitForSystemEvent();
const defaultCall = (cronIsolatedRun.mock.calls[0] as unknown[] | undefined)?.[0] as
| { sessionKey?: string }
@@ -212,8 +219,15 @@ describe("gateway server hooks", () => {
cronIsolatedRun.mockClear();
cronIsolatedRun.mockResolvedValue({ status: "ok", summary: "done" });
const mappedOk = await postHook(port, "/hooks/mapped-ok", { subject: "hello", id: "42" });
expect(mappedOk.status).toBe(202);
const mappedOk = await fetch(`http://127.0.0.1:${port}/hooks/mapped-ok`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer hook-secret",
},
body: JSON.stringify({ subject: "hello", id: "42" }),
});
expect(mappedOk.status).toBe(200);
await waitForSystemEvent();
const mappedCall = (cronIsolatedRun.mock.calls[0] as unknown[] | undefined)?.[0] as
| { sessionKey?: string }
@@ -249,7 +263,7 @@ describe("gateway server hooks", () => {
agentId: "hooks",
sessionKey: "agent:hooks:slack:channel:c123",
});
expect(resAgent.status).toBe(202);
expect(resAgent.status).toBe(200);
await waitForSystemEvent();
const routedCall = (cronIsolatedRun.mock.calls[0] as unknown[] | undefined)?.[0] as
@@ -279,7 +293,7 @@ describe("gateway server hooks", () => {
await withGatewayServer(async ({ port }) => {
mockIsolatedRunOkOnce();
const resNoAgent = await postHook(port, "/hooks/agent", { message: "No explicit agent" });
expect(resNoAgent.status).toBe(202);
expect(resNoAgent.status).toBe(200);
await waitForSystemEvent();
const noAgentCall = (cronIsolatedRun.mock.calls[0] as unknown[] | undefined)?.[0] as {
job?: { agentId?: string };
@@ -292,7 +306,7 @@ describe("gateway server hooks", () => {
message: "Allowed",
agentId: "hooks",
});
expect(resAllowed.status).toBe(202);
expect(resAllowed.status).toBe(200);
await waitForSystemEvent();
const allowedCall = (cronIsolatedRun.mock.calls[0] as unknown[] | undefined)?.[0] as {
job?: { agentId?: string };