{"record":{"id":"7daa4c2b635c1b70","repo":"actualbudget/actual","slug":"simplefin-claim-failed-with-http-response-status","errorCode":null,"errorMessage":"SimpleFIN claim failed with HTTP ${response.status}","messagePattern":"SimpleFIN claim failed with HTTP (.+?)","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"packages/sync-server/src/app-simplefin/app-simplefin.js","lineNumber":365,"sourceCode":"  }\n\n  return decoded;\n}\n\nasync function claimAccessKey(claimUrl) {\n  // Self-hosters may run their own SimpleFIN bridge on the local network, so\n  // private addresses are allowed here; cloud metadata and other always-blocked\n  // ranges are still rejected.\n  await assertUrlAllowed(claimUrl, { allowPrivateNetwork: true });\n\n  // don't auto-follow redirects for SSRF safety\n  const response = await fetch(claimUrl, {\n    method: 'POST',\n    redirect: 'manual',\n  });\n\n  if (!response.ok && response.status !== 403) {\n    throw new Error(`SimpleFIN claim failed with HTTP ${response.status}`);\n  }\n\n  return (await response.text()).trim();\n}\n\nfunction isForbidden(value) {\n  return typeof value === 'string' && value.startsWith('Forbidden');\n}\n\nfunction isInvalidAccessKey(accessKey) {\n  return (\n    typeof accessKey !== 'string' ||\n    isForbidden(accessKey) ||\n    !ACCESS_KEY_FORMAT.test(accessKey)\n  );\n}\n\nasync function getTransactions(accessKey, accounts, startDate, endDate) {","sourceCodeStart":347,"sourceCodeEnd":383,"githubUrl":"https://github.com/actualbudget/actual/blob/d4334cb6e6123f4d3bcea1ad6166608884c7e658/packages/sync-server/src/app-simplefin/app-simplefin.js#L347-L383","documentation":"claimAccessKey POSTs the temporary setup token to SimpleFIN's claim URL and returns the resulting access key text. A 403 is treated as an expected 'not claimable/already claimed' response, but any other non-OK HTTP status causes it to throw 'SimpleFIN claim failed with HTTP <status>'. This signals an upstream problem with the token exchange.","triggerScenarios":"POSTing to the SimpleFIN claim endpoint and receiving e.g. 400, 404, 429, or 5xx; expired setup token returning an unexpected status; SimpleFIN outage; network intermediary returning an error status.","commonSituations":"Setup token older than ~30 minutes (expired) or already claimed with unexpected status handling; rate limiting after repeated setup attempts; transient SimpleFIN server errors during bank setup; a proxy stripping the redirect-manual POST.","solutions":["Generate a new setup token at bridge.simplefin.org and retry the claim — tokens expire quickly.","Check the HTTP status in the message: 429 means back off and retry later; 5xx means wait for SimpleFIN to recover.","Retry after a short delay for transient 5xx errors (the message includes the exact status to branch on).","Verify network/proxy settings if a non-SimpleFIN status appears (e.g. 502 from a corporate proxy)."],"exampleFix":"// before\nconst key = await claimAccessKey(token); // throws on 500\n\n// after\nlet key;\ntry {\n  key = await claimAccessKey(token);\n} catch (e) {\n  if (e.message.includes('HTTP 5')) key = await claimAccessKey(token); // retry transient\n  else throw e;\n}","handlingStrategy":"retry","validationCode":null,"typeGuard":null,"tryCatchPattern":"try {\n  key = await claimAccessKey(token);\n} catch (e) {\n  const m = /HTTP (\\d{3})/.exec(e.message);\n  if (m && +m[1] >= 500) {\n    await sleep(2000);\n    key = await claimAccessKey(token); // retry transient 5xx\n  } else {\n    throw e; // 4xx: token problem, get a fresh token\n  }\n}","preventionTips":["Claim the token immediately after generating it — setup tokens expire fast.","Parse the HTTP status from the message and branch: retry 5xx, regenerate token on 4xx.","Monitor SimpleFIN status before mass setup attempts."],"tags":["simplefin","network","http-error"],"backgroundTag":"http-request-failed","analyzedSha":"d4334cb6e6123f4d3bcea1ad6166608884c7e658","analyzedAt":"2026-08-29T01:02:11.213Z","schemaVersion":2},"datasetVersion":"2026-08-29T02:17:18.158Z"}