Date: 2025-11-11 Purpose: Manual validation of Nextcloud webhook schemas and behavior for vector sync integration (ADR-010)
Successfully tested and validated Nextcloud webhook payloads for file/note events and calendar events. 5 out of 6 webhook types were captured and validated against expected schemas from ADR-010 and Nextcloud documentation. One calendar deletion webhook did not fire during testing (potential Nextcloud issue or configuration).
- Nextcloud Version: 30+ (Docker compose setup)
- Webhook App:
webhook_listeners(bundled, enabled) - MCP Server: Test endpoint at
http://mcp:8000/webhooks/nextcloud - Background Worker: Running with 60s timeout
- Authentication: None (test environment)
| ID | Event Class | Status |
|---|---|---|
| 1 | OCP\Files\Events\Node\NodeCreatedEvent |
✓ Tested |
| 2 | OCP\Files\Events\Node\NodeWrittenEvent |
✓ Tested |
| 3 | OCP\Files\Events\Node\NodeDeletedEvent |
✓ Tested |
| 4 | OCP\Calendar\Events\CalendarObjectCreatedEvent |
✓ Tested |
| 5 | OCP\Calendar\Events\CalendarObjectUpdatedEvent |
✓ Tested |
| 6 | OCP\Calendar\Events\CalendarObjectDeletedEvent |
✗ Not received |
Test Action: Created note via Notes API Trigger Time: 2025-11-11 08:37:25 Webhooks Fired: 3 events (folder creation + file creation + file written)
Payload:
{
"user": {
"uid": "admin",
"displayName": "admin"
},
"time": 1762850245,
"event": {
"class": "OCP\\Files\\Events\\Node\\NodeCreatedEvent",
"node": {
"id": 437,
"path": "/admin/files/Notes/Webhooks/Webhook Test Note.md"
}
}
}Validation:
- ✅ Schema matches ADR-010 specification
- ✅ Contains
userobject withuidanddisplayName - ✅ Contains
time(Unix timestamp) - ✅ Contains
event.class(fully qualified event name) - ✅ Contains
event.node.id(file ID) - ✅ Contains
event.node.path(absolute path)
Observations:
- Creating a note via Notes API triggers 3 webhook events:
NodeCreatedEventfor the parent folder (if new)NodeWrittenEventfor the parent folderNodeCreatedEventfor the actual fileNodeWrittenEventfor the file (sometimes fired 2x)
Test Action: Updated note content via Notes API Trigger Time: 2025-11-11 08:49:20
Payload:
{
"user": {
"uid": "admin",
"displayName": "admin"
},
"time": 1762850960,
"event": {
"class": "OCP\\Files\\Events\\Node\\NodeWrittenEvent",
"node": {
"id": 437,
"path": "/admin/files/Notes/Webhooks/Webhook Test Note.md"
}
}
}Validation:
- ✅ Schema identical to
NodeCreatedEventexcept forevent.class - ✅ Same file ID (437) as creation event
- ✅ Updated timestamp reflects actual modification time
Observations:
- File updates trigger a single
NodeWrittenEvent - No duplicate events fired for update operations
Test Action: Deleted note via Notes API Trigger Time: 2025-11-11 08:51:34 Webhooks Fired: 2 events (file + folder deletion)
Payload:
{
"user": {
"uid": "admin",
"displayName": "admin"
},
"time": 1762851093,
"event": {
"class": "OCP\\Files\\Events\\Node\\NodeDeletedEvent",
"node": {
"path": "/admin/files/Notes/Webhooks/Webhook Test Note.md"
}
}
}Validation:
- ✅ Schema matches ADR-010 specification
⚠️ IMPORTANT: Nonode.idfield in deletion events (onlypath)- ✅ Folder deletion triggered after file deletion (empty folder cleanup)
Observations:
- Critical Difference: Deletion events do NOT include
node.id, onlynode.path - This differs from Create/Write events which include both
idandpath - ADR-010 implementation must handle missing
idfield for deletions - Deleting a file also triggers deletion of empty parent folders
Test Action: Created calendar event via CalDAV PUT Trigger Time: 2025-11-11 08:52:50
Payload (partial - calendarData omitted for brevity):
{
"user": {
"uid": "admin",
"displayName": "admin"
},
"time": 1762851169,
"event": {
"calendarId": 1,
"class": "OCP\\Calendar\\Events\\CalendarObjectCreatedEvent",
"calendarData": {
"id": 1,
"uri": "personal",
"{http://calendarserver.org/ns/}getctag": "...",
"{http://sabredav.org/ns}sync-token": 21,
"{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set": [],
"{urn:ietf:params:xml:ns:caldav}schedule-calendar-transp": [],
"{urn:ietf:params:xml:ns:caldav}calendar-timezone": null
},
"objectData": {
"id": 3,
"uri": "webhook-test-event-001.ics",
"lastmodified": 1762851169,
"etag": "\"2b937b7d77dc83c77329dfdb210ba9d0\"",
"calendarid": 1,
"size": 297,
"component": "vevent",
"classification": 0,
"uid": "webhook-test-event-001@nextcloud",
"calendardata": "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n...",
"{http://nextcloud.com/ns}deleted-at": null
},
"shares": []
}
}Validation:
- ✅ Schema matches Nextcloud documentation
- ✅ Contains complete calendar metadata (
calendarData) - ✅ Contains complete event data (
objectData) - ✅ Includes full iCal data in
objectData.calendardata - ✅ Includes
objectData.idfor database lookups ⚠️ Complex: Much more metadata than file events
Observations:
- Calendar webhooks include significantly more data than file webhooks
- Full iCal content is embedded in
objectData.calendardata - Event ID is in
objectData.id(NOTevent.id) calendarDatacontains calendar-level metadatasharesarray contains sharing information (empty in this test)
Test Action: Updated calendar event via CalDAV PUT Trigger Time: 2025-11-11 08:53:28
Payload (partial):
{
"user": {
"uid": "admin",
"displayName": "admin"
},
"time": 1762851207,
"event": {
"calendarId": 1,
"class": "OCP\\Calendar\\Events\\CalendarObjectUpdatedEvent",
"calendarData": { /* same structure as creation */ },
"objectData": {
"id": 3,
"uri": "webhook-test-event-001.ics",
"lastmodified": 1762851207,
"etag": "\"2695a18013e0991e4212b07b61d5e1e2\"",
"calendarid": 1,
"size": 315,
"component": "vevent",
"classification": 0,
"uid": "webhook-test-event-001@nextcloud",
"calendardata": "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n...",
"{http://nextcloud.com/ns}deleted-at": null
},
"shares": []
}
}Validation:
- ✅ Schema identical to
CalendarObjectCreatedEventexceptevent.class - ✅ Same event ID (3) as creation
- ✅ Updated
lastmodifiedtimestamp - ✅ Different
etag(changed from creation) - ✅ Larger
size(315 vs 297 bytes)
Observations:
- Update events contain full new state (not delta)
- ETag changes on updates (useful for conflict detection)
- Size field reflects actual iCal size
Test Action: Deleted calendar event via CalDAV DELETE Trigger Time: 2025-11-11 08:54:47 Status: ❌ WEBHOOK DID NOT FIRE
Expected Payload (from Nextcloud docs):
{
"user": {
"uid": "admin",
"displayName": "admin"
},
"time": <timestamp>,
"event": {
"calendarId": 1,
"class": "OCP\\Calendar\\Events\\CalendarObjectDeletedEvent",
"calendarData": { /* calendar metadata */ },
"objectData": {
"id": 3,
"uri": "webhook-test-event-001.ics",
/* ... other fields ... */
},
"shares": []
}
}Issue:
- Calendar event was successfully deleted (verified via CalDAV PROPFIND)
- Webhook registration confirmed (ID #6 in
webhook_listeners:list) - Background worker running and processing other events
- No webhook notification received after 2+ minutes
Possible Causes:
- Known Nextcloud bug with calendar deletion webhooks
- CalDAV DELETE may not trigger event system properly
- Deletion event may require trash bin enabled
- Background job may have silently failed
Recommended Actions:
- File Nextcloud issue report
- Test with trash bin enabled (
CalendarObjectMovedToTrashEvent) - Check Nextcloud error logs for webhook failures
- Verify with Nextcloud 31+ if issue persists
| Field | Expected (ADR-010) | Actual | Match |
|---|---|---|---|
user.uid |
string | string | ✅ |
user.displayName |
string | string | ✅ |
time |
int | int | ✅ |
event.class |
string | string | ✅ |
event.node.id |
string | int | |
event.node.path |
string | string | ✅ |
Type Discrepancy: node.id is documented as string but returns as int (437 instead of "437")
| Field | Expected (Nextcloud docs) | Actual | Match |
|---|---|---|---|
user.uid |
string | string | ✅ |
user.displayName |
string | string | ✅ |
time |
int | int | ✅ |
event.class |
string | string | ✅ |
event.calendarId |
int | int | ✅ |
event.calendarData.* |
object | object | ✅ |
event.objectData.id |
int | int | ✅ |
event.objectData.uri |
string | string | ✅ |
event.objectData.calendardata |
string | string | ✅ |
event.objectData.lastmodified |
int | int | ✅ |
event.objectData.etag |
string | string | ✅ |
event.objectData.component |
string|null | string | ✅ |
event.shares |
array | array | ✅ |
All calendar event fields match expected schemas.
- File Deletions: No
node.idfield, onlynode.path - Calendar Deletions: Not tested (webhook didn't fire)
- Impact: Webhook handler must check for
node.idexistence before using it
- Creating a note triggers 3-5 webhook events
- Deleting a note triggers 2 events (file + folder)
- Impact: Deduplication logic needed in webhook handler
- File events:
event.node.id - Calendar events:
event.objectData.id - Impact: Event parser must handle different ID field locations
- All webhooks contain complete current state (not delta)
- Impact: No need for "previous state" tracking in webhook handler
- Calendar webhooks include full iCal content
- Impact: Can extract all event metadata without additional API calls
def extract_document_task(event_class: str, payload: dict) -> DocumentTask | None:
"""Extract DocumentTask from webhook event payload."""
user_id = payload["user"]["uid"]
event_data = payload["event"]
# File/Note events
if "NodeCreatedEvent" in event_class or "NodeWrittenEvent" in event_class:
path = event_data["node"]["path"]
# Only process markdown files for notes
if not path.endswith(".md"):
return None
# IMPORTANT: Check if 'id' exists (missing in deletion events)
doc_id = str(event_data["node"].get("id", ""))
if not doc_id:
# For missing ID, use path-based identifier
doc_id = f"path:{path}"
return DocumentTask(
user_id=user_id,
doc_id=doc_id,
doc_type="note",
operation="index",
modified_at=payload["time"],
)
# File deletion events
elif "NodeDeletedEvent" in event_class:
path = event_data["node"]["path"]
if not path.endswith(".md"):
return None
# Deletion events DON'T have node.id - use path
return DocumentTask(
user_id=user_id,
doc_id=f"path:{path}", # Path-based since ID unavailable
doc_type="note",
operation="delete",
modified_at=payload["time"],
)
# Calendar creation/update events
elif "CalendarObjectCreatedEvent" in event_class or \
"CalendarObjectUpdatedEvent" in event_class:
return DocumentTask(
user_id=user_id,
doc_id=str(event_data["objectData"]["id"]),
doc_type="calendar_event",
operation="index",
modified_at=event_data["objectData"]["lastmodified"],
)
# Calendar deletion events
elif "CalendarObjectDeletedEvent" in event_class:
return DocumentTask(
user_id=user_id,
doc_id=str(event_data["objectData"]["id"]),
doc_type="calendar_event",
operation="delete",
modified_at=payload["time"],
)
return None # Unsupported event typeProblem: Creating a note triggers 3-5 webhooks Solution: Idempotent processing + task deduplication
# In webhook handler
async def handle_nextcloud_webhook(request: Request) -> JSONResponse:
payload = await request.json()
task = extract_document_task(
payload["event"]["class"],
payload
)
if task:
# Idempotent: Queue will only process latest version
await document_queue.send(task)
return JSONResponse({"status": "received"}, status_code=200)Since deletion events lack node.id, use path-based identification:
# In Qdrant delete logic
async def delete_document(user_id: str, doc_id: str, doc_type: str):
if doc_id.startswith("path:"):
# Path-based deletion
path = doc_id.removeprefix("path:")
# Search Qdrant for document with matching path in metadata
points = await qdrant.scroll(
collection_name=collection,
scroll_filter=Filter(must=[
FieldCondition(
key="user_id",
match=MatchValue(value=user_id),
),
FieldCondition(
key="metadata.path",
match=MatchValue(value=path),
),
]),
)
# Delete found points
else:
# ID-based deletion (normal case)
...To reduce webhook volume, add filters:
{
"httpMethod": "POST",
"uri": "http://mcp:8000/webhooks/nextcloud",
"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent",
"eventFilter": {
"event.node.path": "/^.*\\.md$/"
}
}This filters to only .md files at the webhook registration level (not handler level).
Add webhook-specific metrics:
webhook_notifications_received_total{event_type="note_created"} 42
webhook_processing_duration_seconds{event_type="note_created"} 0.023
webhook_errors_total{error_type="parse_error"} 2
webhook_duplicates_filtered_total{doc_type="note"} 15- File creation webhook triggers document indexing
- File update webhook triggers reindexing
- File deletion webhook triggers document removal
- File deletion without ID successfully removes document (path-based)
- Calendar creation webhook triggers event indexing
- Calendar update webhook triggers event reindexing
- Calendar deletion webhook triggers event removal (NOT TESTED - webhook didn't fire)
- Duplicate webhooks are deduplicated
- Non-markdown file webhooks are ignored
- Malformed webhook payloads return 400 error
- Webhook authentication validates shared secret
- Webhook processing completes within 50ms
Complete webhook logs with full payloads are available in MCP container logs:
docker compose logs mcp | grep -A 30 "🔔 Webhook received"Nextcloud webhooks work as documented with minor exceptions:
- ✅ File/Note Events: Fully functional and match expected schemas
- ✅ Calendar Creation/Update: Fully functional with rich metadata
- ❌ Calendar Deletion: Webhook did not fire (requires investigation)
⚠️ Schema Discrepancy:node.idis integer (not string as documented)⚠️ Deletion Schema: Missingnode.idfield (onlypathprovided)
Overall Status: Ready for ADR-010 implementation with noted caveats. Calendar deletion webhook issue should be reported to Nextcloud and may require alternative approach (polling or trash bin events).