odysseus-dev/odysseus · error · HTTPException

Not found

Error message

Not found

What it means

HTTP 404 from POST /api/tasks/{task_id}/webhook/{token}. The lookup is a single three-condition query: id must match, webhook_token must match, and status must be 'active'. The 404 is deliberately generic so callers cannot distinguish a bad token from an inactive task, preventing token enumeration.

Source

Thrown at routes/task_routes.py:1056

            {"name": "document_created", "description": "Fires when a document is created"},
            {"name": "memory_added", "description": "Fires when a memory is added"},
            {"name": "research_completed", "description": "Fires when a research report completes"},
            {"name": "email_received", "description": "Fires when new inbox mail is observed"},
            {"name": "skill_added", "description": "Fires when a new skill is created"},
        ]}

    @router.post("/{task_id}/webhook/{token}")
    async def webhook_trigger(task_id: str, token: str):
        """Unauthenticated endpoint — the token IS the auth."""
        db = SessionLocal()
        try:
            task = db.query(ScheduledTask).filter(
                ScheduledTask.id == task_id,
                ScheduledTask.webhook_token == token,
                ScheduledTask.status == "active",
            ).first()
            if not task:
                raise HTTPException(404, "Not found")
            if (
                is_admin_only_task_action(task.task_type, task.action)
                and not owner_has_admin_task_privileges(task.owner)
            ):
                task.status = "paused"
                task.next_run = None
                db.commit()
                raise HTTPException(403, f"Action '{task.action}' requires admin privileges")
        finally:
            db.close()
        started = await task_scheduler.run_task_now(task_id)
        if not started:
            raise HTTPException(409, "Task is already running")
        return {"ok": True, "message": "Task triggered via webhook"}

    @router.post("/{task_id}/webhook-regenerate")
    async def regenerate_webhook(request: Request, task_id: str):
        user = _owner(request)

View on GitHub (pinned to f9235ebbf1)

Solutions

  1. Re-fetch the current webhook_token from the task detail endpoint and confirm status=='active' before triggering
  2. If the task is paused, resume it (set status back to 'active') and retry
  3. Regenerate the token via POST /api/tasks/{task_id}/webhook-regenerate and update the external caller with the new URL
  4. URL-encode the token if it may contain characters that get mangled in transit

Example fix

# before
curl -X POST https://host/api/tasks/abc/webhook/OLD_TOKEN   # 404
# after
curl -X POST https://host/api/tasks/abc/webhook/$(get_current_token)  # fetch fresh token first
Defensive patterns

Strategy: validation

Validate before calling

// Before triggering, verify task state and token freshness
const task = await getTask(taskId);
if (task.status !== 'active' || !task.webhook_token) throw new Error('task not triggerable');
await fetch(`${base}/api/tasks/${taskId}/webhook/${encodeURIComponent(task.webhook_token)}`, {method:'POST'});

Try / catch

if (resp.status === 404) { await refreshTaskAndToken(); scheduleRetry(); }

Prevention

When it happens

Trigger: Calling the webhook with a stale token after it was regenerated via /webhook-regenerate; triggering a task whose status is 'paused' or anything other than 'active' (including one auto-paused by the admin-privilege guard); a typo'd or truncated task_id or token in the URL.

Common situations: Token rotated for security and the old cron job / external scheduler still fires the old URL; task paused manually or by the system while the external trigger was not updated; trailing whitespace or URL-encoding damage when copying the token.

Related errors


AI-assisted analysis of odysseus-dev/odysseus@f9235ebbf1 (2026-08-14). Data as JSON: /api/errors/e050449882e3e018. Report an issue: GitHub.