refactor: fix webhook create/update serialization, add payload types, and MEMORY_CATEGORIZED event (#4429)

Co-authored-by: Saket Aryan <saketaryan2002@gmail.com>
This commit is contained in:
Kartik
2026-03-19 22:26:17 +05:30
committed by GitHub
co-authored by Saket Aryan
parent 88abb29de9
commit 6663b738d5
11 changed files with 362 additions and 128 deletions
+31
View File
@@ -7,6 +7,23 @@ mode: "wide"
<Tabs>
<Tab title="Python">
<Update label="2026-03-19" description="v1.0.7">
**Bug Fixes:**
- **Core:** Fixed control characters in LLM JSON responses causing parse failures (#4420)
- **Core:** Replaced hardcoded US/Pacific timezone references with `timezone.utc` (#4404)
- **Core:** Preserved `http_auth` in `_safe_deepcopy_config` for OpenSearch (#4418)
- **Core:** Normalized malformed LLM fact output before embedding (#4224)
- **Embeddings:** Pass `encoding_format='float'` in OpenAI embeddings for proxy compatibility (#4058)
- **LLMs:** Fixed Ollama to pass tools to `client.chat` and parse `tool_calls` from response (#4176)
- **Reranker:** Support nested LLM config in `LLMReranker` for non-OpenAI providers (#4405)
- **Vector Stores:** Cast `vector_distance` to float in Redis search (#4377)
**Improvements:**
- **Embeddings:** Improved Ollama embedder with model name normalization and error handling (#4403)
</Update>
<Update label="2026-03-16" description="v1.0.6">
**Bug Fixes:**
@@ -745,6 +762,20 @@ mode: "wide"
<Tab title="TypeScript">
<Update label="2026-03-19" description="v2.4.2">
**Bug Fixes:**
- **Client:** Fixed webhook `createWebhook` and `updateWebhook` API serialization
- **Client:** Added missing `MEMORY_CATEGORIZED` event type to `WebhookEvent` enum
- **Types:** Added `WebhookCreatePayload` and `WebhookUpdatePayload` for better type safety
**Tests:**
- Added end-to-end unit test coverage for the platform client — CRUD, batch, search, webhooks, users, project, and initialization (#4357)
- Added real API integration tests for memory CRUD, batch operations, search, user management, project configuration, and webhook lifecycle (#4395)
- Deleted obsolete e2e test files replaced by the new structured test suite (#4419)
</Update>
<Update label="2026-03-16" description="v2.4.1">
**Bug Fixes:**
+2 -1
View File
@@ -2,6 +2,7 @@
module.exports = {
...require("./jest.config"),
testMatch: ["**/integration/**/*.test.ts"],
globalSetup: "<rootDir>/src/client/tests/integration/global-setup.ts",
globalTeardown: "<rootDir>/src/client/tests/integration/global-teardown.ts",
// Run integration tests serially to avoid rate limiting and race conditions
maxWorkers: 1,
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mem0ai",
"version": "2.4.1",
"version": "2.4.2",
"description": "The Memory Layer For Your AI Apps",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
+2 -1
View File
@@ -12,7 +12,8 @@ export type {
PromptUpdatePayload,
SearchOptions,
Webhook,
WebhookPayload,
WebhookCreatePayload,
WebhookUpdatePayload,
Messages,
Message,
AllUsers,
+17 -9
View File
@@ -10,7 +10,8 @@ import {
PromptUpdatePayload,
SearchOptions,
Webhook,
WebhookPayload,
WebhookCreatePayload,
WebhookUpdatePayload,
Message,
FeedbackPayload,
CreateMemoryExportPayload,
@@ -671,33 +672,40 @@ export default class MemoryClient {
return response;
}
async createWebhook(webhook: WebhookPayload): Promise<Webhook> {
async createWebhook(webhook: WebhookCreatePayload): Promise<Webhook> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("create_webhook", []);
const body = {
name: webhook.name,
url: webhook.url,
event_types: webhook.eventTypes,
};
const response = await this._fetchWithErrorHandling(
`${this.host}/api/v1/webhooks/projects/${this.projectId}/`,
{
method: "POST",
headers: this.headers,
body: JSON.stringify(webhook),
body: JSON.stringify(body),
},
);
return response;
}
async updateWebhook(webhook: WebhookPayload): Promise<{ message: string }> {
async updateWebhook(
webhook: WebhookUpdatePayload,
): Promise<{ message: string }> {
if (this.telemetryId === "") await this.ping();
this._captureEvent("update_webhook", []);
const project_id = webhook.projectId || this.projectId;
const body: Record<string, any> = {};
if (webhook.name != null) body.name = webhook.name;
if (webhook.url != null) body.url = webhook.url;
if (webhook.eventTypes != null) body.event_types = webhook.eventTypes;
const response = await this._fetchWithErrorHandling(
`${this.host}/api/v1/webhooks/${webhook.webhookId}/`,
{
method: "PUT",
headers: this.headers,
body: JSON.stringify({
...webhook,
projectId: project_id,
}),
body: JSON.stringify(body),
},
);
return response;
+10 -4
View File
@@ -174,6 +174,7 @@ export enum WebhookEvent {
MEMORY_ADDED = "memory_add",
MEMORY_UPDATED = "memory_update",
MEMORY_DELETED = "memory_delete",
MEMORY_CATEGORIZED = "memory_categorize",
}
export interface Webhook {
@@ -187,12 +188,17 @@ export interface Webhook {
event_types?: WebhookEvent[];
}
export interface WebhookPayload {
eventTypes: WebhookEvent[];
projectId: string;
webhookId: string;
export interface WebhookCreatePayload {
name: string;
url: string;
eventTypes: WebhookEvent[];
}
export interface WebhookUpdatePayload {
webhookId: string;
name?: string;
url?: string;
eventTypes?: WebhookEvent[];
}
export interface FeedbackPayload {
@@ -177,23 +177,6 @@ describeIntegration("MemoryClient Integration — CRUD", () => {
});
});
// ─── Delete single ────────────────────────────────────────
describe("delete memory", () => {
test("deletes a single memory by ID", async () => {
const memoryId = memoryIds[0];
expect(memoryId).toBeDefined();
const result = await client.delete(memoryId);
expect(result).toBeDefined();
expect(typeof result.message).toBe("string");
});
test("getting deleted memory throws MemoryError", async () => {
const memoryId = memoryIds[0];
await expect(client.get(memoryId)).rejects.toThrow(MemoryError);
});
});
// ─── Edge cases ──────────────────────────────────────────
describe("edge cases", () => {
test("add with metadata attaches metadata to the memory", async () => {
@@ -234,6 +217,25 @@ describeIntegration("MemoryClient Integration — CRUD", () => {
});
});
// ─── Delete single ────────────────────────────────────────
// NOTE: Delete tests run last to avoid race conditions with
// other tests that depend on the seeded memories.
describe("delete memory", () => {
test("deletes a single memory by ID", async () => {
const memoryId = memoryIds[0];
expect(memoryId).toBeDefined();
const result = await client.delete(memoryId);
expect(result).toBeDefined();
expect(typeof result.message).toBe("string");
});
test("getting deleted memory throws MemoryError", async () => {
const memoryId = memoryIds[0];
await expect(client.get(memoryId)).rejects.toThrow(MemoryError);
});
});
// ─── Delete all + delete user ─────────────────────────────
describe("cleanup operations", () => {
test("deletes all memories for test user", async () => {
+23 -10
View File
@@ -51,43 +51,56 @@ export async function withRetry<T>(
* Poll getAll until memories appear for a user.
* The Mem0 API processes memories asynchronously — after add()
* we need to wait for them to be available.
*
* Polls every 15 seconds with a maximum of 4 retries to avoid
* hitting rate limits. Throws if results aren't available after
* all retries.
*/
export async function waitForMemories(
client: MemoryClient,
userId: string,
minCount: number,
maxWaitMs = 60_000,
maxRetries = 4,
): Promise<Memory[]> {
const start = Date.now();
while (Date.now() - start < maxWaitMs) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
const memories = await withRetry(() => client.getAll({ user_id: userId }));
if (Array.isArray(memories) && memories.length >= minCount) {
return memories;
}
await new Promise((r) => setTimeout(r, 3_000));
if (attempt < maxRetries) {
await new Promise((r) => setTimeout(r, 15_000));
}
}
return await client.getAll({ user_id: userId });
throw new Error(
`waitForMemories: expected at least ${minCount} memories for user "${userId}" but did not get them after ${maxRetries} attempts`,
);
}
/**
* Poll search until results appear. Only used by search tests —
* other test files should NOT call this to avoid wasting API credits.
*
* Polls every 15 seconds with a maximum of 4 retries. Throws if
* no results are found after all retries.
*/
export async function waitForSearchResults(
client: MemoryClient,
query: string,
options: Record<string, any>,
maxWaitMs = 60_000,
maxRetries = 4,
): Promise<Memory[]> {
const start = Date.now();
while (Date.now() - start < maxWaitMs) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
const results = await withRetry(() => client.search(query, options));
if (Array.isArray(results) && results.length > 0) {
return results;
}
await new Promise((r) => setTimeout(r, 3_000));
if (attempt < maxRetries) {
await new Promise((r) => setTimeout(r, 15_000));
}
}
return await client.search(query, options);
throw new Error(
`waitForSearchResults: no results for query "${query}" after ${maxRetries} attempts`,
);
}
/**
@@ -1,15 +1,12 @@
/**
* Integration tests: User management and project configuration.
* Integration tests: User management, project configuration, and webhooks.
*
* Tests users(), getProject(), and updateProject() against the real API.
*
* Note: Webhook tests (createWebhook, updateWebhook) are excluded because
* the SDK has a known bug where it sends camelCase keys (eventTypes) instead
* of snake_case (event_types). These will be added once the SDK is fixed.
* Tests users(), getProject(), updateProject(), and webhook CRUD against the real API.
*
* Run: MEM0_API_KEY=your-key npx jest management.test.ts --forceExit
*/
import { MemoryClient } from "../../mem0";
import { WebhookEvent } from "../../mem0.types";
import { randomUUID } from "crypto";
import {
describeIntegration,
@@ -17,6 +14,7 @@ import {
suppressTelemetryNoise,
seedTestMemories,
cleanupTestUser,
withRetry,
} from "./helpers";
jest.setTimeout(120_000);
@@ -95,4 +93,115 @@ describeIntegration("MemoryClient Integration — Users & Project", () => {
});
});
});
// ─── Webhooks ──────────────────────────────────────────────
describe("webhook management", () => {
let createdWebhookId: string;
const hookName = `test-hook-${randomUUID().slice(0, 8)}`;
const hookUrl = `https://example.com/webhook/${randomUUID().slice(0, 8)}`;
const updatedName = `updated-hook-${randomUUID().slice(0, 8)}`;
afterAll(async () => {
if (createdWebhookId) {
try {
await client.deleteWebhook({ webhookId: createdWebhookId });
} catch {
// ignore — may already be deleted
}
}
});
// ─── Create ────────────────────────────────────────────
test("createWebhook returns a webhook_id", async () => {
const result = await withRetry(() =>
client.createWebhook({
name: hookName,
url: hookUrl,
eventTypes: [WebhookEvent.MEMORY_ADDED, WebhookEvent.MEMORY_UPDATED],
}),
);
createdWebhookId = result.webhook_id!;
expect(result.webhook_id).toBeDefined();
});
test("createWebhook returns the correct name", async () => {
const webhooks = await withRetry(() => client.getWebhooks());
const wh = webhooks.find((w) => w.webhook_id === createdWebhookId);
expect(wh!.name).toBe(hookName);
});
test("createWebhook returns the correct url", async () => {
const webhooks = await withRetry(() => client.getWebhooks());
const wh = webhooks.find((w) => w.webhook_id === createdWebhookId);
expect(wh!.url).toBe(hookUrl);
});
test("createWebhook returns the correct event_types", async () => {
const webhooks = await withRetry(() => client.getWebhooks());
const wh = webhooks.find((w) => w.webhook_id === createdWebhookId);
expect(wh!.event_types?.sort()).toStrictEqual(
[WebhookEvent.MEMORY_ADDED, WebhookEvent.MEMORY_UPDATED].sort(),
);
});
// ─── List ──────────────────────────────────────────────
test("getWebhooks returns an array", async () => {
const webhooks = await withRetry(() => client.getWebhooks());
expect(Array.isArray(webhooks)).toBe(true);
});
test("getWebhooks includes the created webhook", async () => {
const webhooks = await withRetry(() => client.getWebhooks());
const found = webhooks.find((w) => w.webhook_id === createdWebhookId);
expect(found).toBeDefined();
});
test("getWebhooks shows the webhook as active", async () => {
const webhooks = await withRetry(() => client.getWebhooks());
const found = webhooks.find((w) => w.webhook_id === createdWebhookId);
expect(found!.is_active).toBe(true);
});
// ─── Update ────────────────────────────────────────────
test("updateWebhook returns a success message", async () => {
const result = await withRetry(() =>
client.updateWebhook({
webhookId: createdWebhookId,
name: updatedName,
url: "https://example.com/updated",
eventTypes: [WebhookEvent.MEMORY_DELETED],
}),
);
expect(result.message).toBeDefined();
});
test("updateWebhook persists the new name", async () => {
const webhooks = await withRetry(() => client.getWebhooks());
const updated = webhooks.find((w) => w.webhook_id === createdWebhookId);
expect(updated!.name).toBe(updatedName);
});
test("updateWebhook persists the new event_types", async () => {
const webhooks = await withRetry(() => client.getWebhooks());
const updated = webhooks.find((w) => w.webhook_id === createdWebhookId);
expect(updated!.event_types?.sort()).toStrictEqual(
[WebhookEvent.MEMORY_DELETED].sort(),
);
});
// ─── Delete ────────────────────────────────────────────
test("deleteWebhook returns a response", async () => {
const result = await withRetry(() =>
client.deleteWebhook({ webhookId: createdWebhookId }),
);
expect(result).toBeDefined();
});
test("deleteWebhook removes the webhook from the list", async () => {
const webhooks = await withRetry(() => client.getWebhooks());
const found = webhooks.find((w) => w.webhook_id === createdWebhookId);
expect(found).toBeUndefined();
createdWebhookId = "";
});
});
});
@@ -1,6 +1,7 @@
/**
* MemoryClient unit tests — getWebhooks, createWebhook, updateWebhook, deleteWebhook.
* Tests verify request URL and HTTP method, not mock response values.
* Tests verify request URL, HTTP method, and payload serialization.
* One expect per test case.
*/
import { MemoryClient } from "../mem0";
import { WebhookEvent } from "../mem0.types";
@@ -14,17 +15,26 @@ import {
installConsoleSuppression();
describe("MemoryClient - Webhooks", () => {
test("getWebhooks sends GET to /api/v1/webhooks/projects/:id/", async () => {
// ─── Helpers ──────────────────────────────────────────────
function webhookMock(extra?: Map<string, { status: number; body: unknown }>) {
return setupMockFetch(extra);
}
function createClient() {
return new MemoryClient({
apiKey: TEST_API_KEY,
organizationId: TEST_ORG_ID,
projectId: TEST_PROJECT_ID,
});
}
// ─── getWebhooks ──────────────────────────────────────────
describe("MemoryClient - getWebhooks", () => {
test("sends GET to /api/v1/webhooks/projects/:id/", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/api/v1/webhooks/projects/", { status: 200, body: [] });
const mock = setupMockFetch(extra);
const client = new MemoryClient({
apiKey: TEST_API_KEY,
organizationId: TEST_ORG_ID,
projectId: TEST_PROJECT_ID,
});
const mock = webhookMock(extra);
const client = createClient();
await client.getWebhooks();
const call = mock.mock.calls.find(
@@ -33,117 +43,170 @@ describe("MemoryClient - Webhooks", () => {
);
expect(call).toBeDefined();
});
});
test("createWebhook sends POST to /api/v1/webhooks/projects/:id/", async () => {
// ─── createWebhook ────────────────────────────────────────
describe("MemoryClient - createWebhook", () => {
async function callCreate() {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/api/v1/webhooks/projects/", {
status: 200,
body: { webhook_id: "wh_new" },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({
apiKey: TEST_API_KEY,
organizationId: TEST_ORG_ID,
projectId: TEST_PROJECT_ID,
});
const mock = webhookMock(extra);
const client = createClient();
await client.createWebhook({
name: "new-hook",
url: "https://example.com",
eventTypes: [WebhookEvent.MEMORY_ADDED],
projectId: TEST_PROJECT_ID,
webhookId: "",
});
return mock;
}
test("sends POST to /api/v1/webhooks/projects/:id/", async () => {
const mock = await callCreate();
expect(findFetchCall(mock, "/api/v1/webhooks/", "POST")).toBeDefined();
});
test("createWebhook includes webhook payload in body", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/api/v1/webhooks/projects/", {
status: 200,
body: { webhook_id: "wh_new" },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({
apiKey: TEST_API_KEY,
organizationId: TEST_ORG_ID,
projectId: TEST_PROJECT_ID,
});
await client.createWebhook({
name: "new-hook",
url: "https://example.com",
eventTypes: [WebhookEvent.MEMORY_ADDED],
projectId: TEST_PROJECT_ID,
webhookId: "",
});
const call = findFetchCall(mock, "/api/v1/webhooks/", "POST");
const body = getFetchBody(call!);
test("body contains name", async () => {
const mock = await callCreate();
const body = getFetchBody(
findFetchCall(mock, "/api/v1/webhooks/", "POST")!,
);
expect(body.name).toBe("new-hook");
});
test("body contains url", async () => {
const mock = await callCreate();
const body = getFetchBody(
findFetchCall(mock, "/api/v1/webhooks/", "POST")!,
);
expect(body.url).toBe("https://example.com");
});
test("updateWebhook sends PUT to /api/v1/webhooks/:id/", async () => {
test("body contains event_types in snake_case", async () => {
const mock = await callCreate();
const body = getFetchBody(
findFetchCall(mock, "/api/v1/webhooks/", "POST")!,
);
expect(body.event_types).toStrictEqual([WebhookEvent.MEMORY_ADDED]);
});
test("body does not contain camelCase eventTypes", async () => {
const mock = await callCreate();
const body = getFetchBody(
findFetchCall(mock, "/api/v1/webhooks/", "POST")!,
);
expect(body.eventTypes).toBeUndefined();
});
test("body does not contain projectId", async () => {
const mock = await callCreate();
const body = getFetchBody(
findFetchCall(mock, "/api/v1/webhooks/", "POST")!,
);
expect(body.projectId).toBeUndefined();
});
test("body does not contain webhookId", async () => {
const mock = await callCreate();
const body = getFetchBody(
findFetchCall(mock, "/api/v1/webhooks/", "POST")!,
);
expect(body.webhookId).toBeUndefined();
});
});
// ─── updateWebhook ────────────────────────────────────────
describe("MemoryClient - updateWebhook", () => {
async function callUpdate() {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/api/v1/webhooks/wh_1/", {
status: 200,
body: { message: "Webhook updated" },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({
apiKey: TEST_API_KEY,
organizationId: TEST_ORG_ID,
projectId: TEST_PROJECT_ID,
});
const mock = webhookMock(extra);
const client = createClient();
await client.updateWebhook({
webhookId: "wh_1",
name: "updated-hook",
url: "https://new-url.com",
eventTypes: [WebhookEvent.MEMORY_ADDED],
projectId: TEST_PROJECT_ID,
});
return mock;
}
test("sends PUT to /api/v1/webhooks/:id/", async () => {
const mock = await callUpdate();
expect(findFetchCall(mock, "/api/v1/webhooks/wh_1/", "PUT")).toBeDefined();
});
test("updateWebhook includes updated fields in body", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/api/v1/webhooks/wh_1/", {
status: 200,
body: { message: "Webhook updated" },
});
const mock = setupMockFetch(extra);
const client = new MemoryClient({
apiKey: TEST_API_KEY,
organizationId: TEST_ORG_ID,
projectId: TEST_PROJECT_ID,
});
await client.updateWebhook({
webhookId: "wh_1",
name: "updated-hook",
url: "https://new-url.com",
eventTypes: [WebhookEvent.MEMORY_ADDED],
projectId: TEST_PROJECT_ID,
});
const call = findFetchCall(mock, "/api/v1/webhooks/wh_1/", "PUT");
const body = getFetchBody(call!);
test("body contains name", async () => {
const mock = await callUpdate();
const body = getFetchBody(
findFetchCall(mock, "/api/v1/webhooks/wh_1/", "PUT")!,
);
expect(body.name).toBe("updated-hook");
});
test("body contains url", async () => {
const mock = await callUpdate();
const body = getFetchBody(
findFetchCall(mock, "/api/v1/webhooks/wh_1/", "PUT")!,
);
expect(body.url).toBe("https://new-url.com");
});
test("deleteWebhook sends DELETE to /api/v1/webhooks/:id/", async () => {
test("body contains event_types in snake_case", async () => {
const mock = await callUpdate();
const body = getFetchBody(
findFetchCall(mock, "/api/v1/webhooks/wh_1/", "PUT")!,
);
expect(body.event_types).toStrictEqual([WebhookEvent.MEMORY_ADDED]);
});
test("body does not contain camelCase eventTypes", async () => {
const mock = await callUpdate();
const body = getFetchBody(
findFetchCall(mock, "/api/v1/webhooks/wh_1/", "PUT")!,
);
expect(body.eventTypes).toBeUndefined();
});
test("body does not contain project_id", async () => {
const mock = await callUpdate();
const body = getFetchBody(
findFetchCall(mock, "/api/v1/webhooks/wh_1/", "PUT")!,
);
expect(body.project_id).toBeUndefined();
});
test("body does not contain projectId", async () => {
const mock = await callUpdate();
const body = getFetchBody(
findFetchCall(mock, "/api/v1/webhooks/wh_1/", "PUT")!,
);
expect(body.projectId).toBeUndefined();
});
test("body does not contain webhookId", async () => {
const mock = await callUpdate();
const body = getFetchBody(
findFetchCall(mock, "/api/v1/webhooks/wh_1/", "PUT")!,
);
expect(body.webhookId).toBeUndefined();
});
});
// ─── deleteWebhook ────────────────────────────────────────
describe("MemoryClient - deleteWebhook", () => {
test("sends DELETE to /api/v1/webhooks/:id/", async () => {
const extra = new Map<string, { status: number; body: unknown }>();
extra.set("/api/v1/webhooks/wh_1/", {
status: 200,
body: { message: "Webhook deleted" },
});
const mock = setupMockFetch(extra);
const mock = webhookMock(extra);
const client = new MemoryClient({ apiKey: TEST_API_KEY });
await client.deleteWebhook({ webhookId: "wh_1" });
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "mem0ai"
version = "1.0.6"
version = "1.0.7"
description = "Long-term memory for AI Agents"
authors = [
{ name = "Mem0", email = "founders@mem0.ai" }