{"record":{"id":"9d6451fdd006d030","repo":"Budibase/budibase","slug":"link-token-is-invalid-or-has-expired","errorCode":null,"errorMessage":"Link token is invalid or has expired","messagePattern":"Link token is invalid or has expired","errorType":"http","errorClass":"HTTPError","httpStatus":400,"severity":"error","filePath":"packages/server/src/api/controllers/ai/chatIdentityLinks.ts","lineNumber":213,"sourceCode":"      if (window.opener && !window.opener.closed) {\n        try {\n          window.opener.focus()\n          window.close()\n        } catch (error) {}\n      }\n    </script>\n  </body>\n</html>`\n}\n\nexport async function handoffChatLinkSession(\n  ctx: UserCtx<void, string, { instance: string; token: string }>\n) {\n  const token = resolveToken(ctx.params.token)\n  const session =\n    await sdk.ai.chatIdentityLinks.getChatIdentityLinkSession(token)\n  if (!session) {\n    throw new HTTPError(\"Link token is invalid or has expired\", 400)\n  }\n  assertSessionMatchesInstance({\n    workspaceId: session.workspaceId,\n    instance: ctx.params.instance,\n  })\n\n  if (!ctx.isAuthenticated) {\n    utils.setCookie(\n      ctx,\n      `/api/chat-links/${ctx.params.instance}/${token}/handoff`,\n      CHAT_LINK_RETURN_URL_COOKIE,\n      { sign: false }\n    )\n    ctx.redirect(BUILDER_LOGIN_PATH)\n    return\n  }\n\n  const currentGlobalUserId = getCurrentGlobalUserId(ctx)","sourceCodeStart":195,"sourceCodeEnd":231,"githubUrl":"https://github.com/Budibase/budibase/blob/a81a902e9a8fe55b467d106765f6638f12e35c49/packages/server/src/api/controllers/ai/chatIdentityLinks.ts#L195-L231","documentation":"handoffChatLinkSession looks up the stored chat identity link session by the token in the URL. If sdk.ai.chatIdentityLinks.getChatIdentityLinkSession returns nothing, the token does not exist, was already consumed, or has been cleaned up — so the flow cannot continue and a 400 is thrown.","triggerScenarios":"GET/POST to /api/chat-links/:instance/:token/handoff with a token that was never issued, already used (one-time handoff), or expired/deleted in the data store.","commonSituations":"Clicking a handoff link a second time after it was already completed; links left unused past their expiry/TTL; restarting with an in-memory or dev database that lost the session doc; typo'd or truncated token copied from chat.","solutions":["Request a fresh identity link from the chat channel and use the new token","Verify the token string in the URL is complete and unmodified","Check the backing store for the link session document (it may have been consumed or TTL-expired)","If tokens expire too fast, adjust the session TTL/retention in chatIdentityLinks configuration"],"exampleFix":"// before\nconst session = await getChatIdentityLinkSession(oldToken) // already consumed\n// after\nconst newToken = await createChatIdentityLink(...)\nconst session = await getChatIdentityLinkSession(newToken)","handlingStrategy":"try-catch","validationCode":null,"typeGuard":null,"tryCatchPattern":"try {\n  await api.handoffLink(instance, token)\n} catch (e) {\n  if (e instanceof HTTPError && e.status === 400 && e.message.includes(\"invalid or has expired\")) {\n    // issue a fresh link token in chat and retry once with the new token\n  }\n  throw e\n}","preventionTips":["Treat link tokens as single-use: open handoff links exactly once","Use links promptly before expiry","Persist tokens in durable storage in production, not memory","Copy links verbatim from the chat message"],"tags":["identity-link","token-expired","http-400"],"backgroundTag":"token-invalid-or-expired","analyzedSha":"a81a902e9a8fe55b467d106765f6638f12e35c49","analyzedAt":"2026-08-29T01:03:10.972Z","schemaVersion":2},"datasetVersion":"2026-08-29T02:17:18.158Z"}