{"record":{"id":"5497be6fc865a1a0","repo":"medusajs/medusa","slug":"there-are-revokedapikeys-length-secret-keys-tha","errorCode":null,"errorMessage":"There are ${revokedApiKeys.length} secret keys that are already revoked.","messagePattern":"There are (.+?) secret keys that are already revoked\\.","errorType":"validation","errorClass":"MedusaError","httpStatus":400,"severity":"error","filePath":"packages/modules/api-key/src/services/api-key-module-service.ts","lineNumber":563,"sourceCode":"    if (data.some((k) => !k.revoked_by)) {\n      throw new MedusaError(\n        MedusaError.Types.INVALID_DATA,\n        `You must provide a revoked_by field when revoking a key.`\n      )\n    }\n\n    const revokedApiKeys = await this.apiKeyService_.list(\n      {\n        id: data.map((k) => k.id),\n        type: ApiKeyType.SECRET,\n        revoked_at: { $lt: new Date() },\n      },\n      {},\n      sharedContext\n    )\n\n    if (revokedApiKeys.length) {\n      throw new MedusaError(\n        MedusaError.Types.INVALID_DATA,\n        `There are ${revokedApiKeys.length} secret keys that are already revoked.`\n      )\n    }\n  }\n\n  // These are public keys, so there is no point hashing them.\n  protected static generatePublishableKey(): TokenDTO {\n    const token = \"pk_\" + crypto.randomBytes(32).toString(\"hex\")\n\n    return {\n      rawToken: token,\n      hashedToken: token,\n      salt: \"\",\n      redacted: redactKey(token),\n    }\n  }\n","sourceCodeStart":545,"sourceCodeEnd":581,"githubUrl":"https://github.com/medusajs/medusa/blob/5e06e544a296b9033f20f71f11c559f81a0e5739/packages/modules/api-key/src/services/api-key-module-service.ts#L545-L581","documentation":"The API Key module refuses to revoke a secret key that is already in revoked state, because revocation is a terminal state transition. The message reports how many keys in the batch were already revoked.","triggerScenarios":"Calling revokeApiKeys with the id of a publishable/secret key whose revoked_at is already set — e.g. double-clicking a revoke button, retrying a timed-out revoke request, or re-running a migration/script.","commonSituations":"UI double submissions, retries after network timeouts where the first revoke actually succeeded, idempotency-unaware batch jobs, or stale client state showing the key as active.","solutions":["Fetch the key first and skip if revoked_at is already set (make revoke idempotent in your layer)","Refresh the key list in the UI after revoke and disable the button","Filter already-revoked ids out of the batch before calling revoke"],"exampleFix":"// before\nawait apiKeyModuleService.revoke(ids.map((id) => ({ id, revoked_by: userId })))\n// after\nconst keys = await apiKeyModuleService.listApiKeys({ id: ids })\nconst active = keys.filter((k) => !k.revoked_at)\nawait apiKeyModuleService.revoke(active.map((k) => ({ id: k.id, revoked_by: userId })))","handlingStrategy":"validation","validationCode":"const keys = await apiKeyModuleService.listApiKeys({ id: ids })\nconst toRevoke = keys.filter((k) => !k.revoked_at).map((k) => ({ id: k.id, revoked_by: userId }))\nif (toRevoke.length) await apiKeyModuleService.revoke(toRevoke)","typeGuard":"const isRevoked = (k: { revoked_at: string | null }) => !!k.revoked_at","tryCatchPattern":"try { await revoke(...) } catch (e) { if (e.message.includes('already revoked')) return /* idempotent success */; throw e }","preventionTips":["Make revoke idempotent in your layer by filtering already-revoked keys","Refresh key state in the UI after revoke and disable the action"],"tags":["api-key","idempotency","revocation"],"backgroundTag":"invalid-state-transition","analyzedSha":"5e06e544a296b9033f20f71f11c559f81a0e5739","analyzedAt":"2026-08-27T07:24:39.599Z","schemaVersion":2},"datasetVersion":"2026-08-27T08:17:20.692Z"}