
# Change the passkey rpId of a connection

`POST /v1/connections/{connection_id}:change-rp-id` · permission `connections:write`

Moves a passkey connection to a new rpId and starts a migration window. A passkey is bound to the rpId it was registered with, so once users have passkeys this is the only way to change the rpId: a `PATCH` that changes `settings.rp_id` is rejected with `409 rp_id_change_requires_migration`.

During the window, users can still sign in with passkeys of the previous rpId, and new passkeys are registered on the new rpId. When the window ends, or when you end it early with [Complete the rpId migration](complete-rp-id-migration.md), the passkeys that are still on the previous rpId are revoked (reason `rp_id_changed`). Users who have no other sign-in method then need passwordless email sign-in or account recovery. Only one migration can run at a time in an environment.

The connection's rpId, the migration record, the management audit entry and the `passkey_rp_id.migration_started` event are written together. A custom domain that is the rpId, or the previous rpId of a running migration, cannot be deleted (`409 domain_in_use_by_rp_id`).

## Request

| Field | Type | Required | Description |
|---|---|---|---|
| `connection_id` | path | yes | Passkey connection ID (`conn_…`). |
| `If-Match` | header | yes | The connection's `ETag` (see [Get a connection](get.md)). |
| `Idempotency-Key` | header | no | Optional key that makes a retried request safe (16-64 characters). |
| `new_rp_id` | string | yes | The new rpId: the host name of an `active` custom domain of the environment (lower-case, IDNA A-label), or an empty string to use each sign-in host as its own rpId. At most 253 bytes. |
| `confirm_rp_id` | string | yes | `new_rp_id` typed again. |
| `migration_window_days` | integer | no | How many days passkeys of the previous rpId keep working: 30-365, default 90. |

```json
{
  "new_rp_id": "login.example.com",
  "confirm_rp_id": "login.example.com",
  "migration_window_days": 90
}
```

## Response

`200 OK`; the migration and an `ETag` header with the connection's new version.

```json
{
  "id": "rpm_01j9xr4a6b8c0d2e4f6g8h0j2k",
  "connection_id": "conn_01j9xp6q0r1s5t7v9w1x3y5z7b",
  "previous_rp_id": null,
  "new_rp_id": "login.example.com",
  "status": "active",
  "started_at": "2026-10-27T10:00:00Z",
  "ends_at": "2027-01-25T10:00:00Z",
  "completed_at": null,
  "revoked_count": null,
  "new_registration_count": null,
  "fallback_enabled_by_migration": false
}
```

| Field | Description |
|---|---|
| `previous_rp_id`, `new_rp_id` | The rpId before and after the change; `null` means each sign-in host is its own rpId. |
| `status` | `active` while the window runs; `completed` when it ended; `completed_early` when it was ended early. |
| `ends_at` | When the window is scheduled to end. |
| `completed_at`, `revoked_count`, `new_registration_count` | Written when the migration closes; `null` while it is `active`. |
| `fallback_enabled_by_migration` | `true` when a fallback sign-in method was turned on for the window because passkeys were the only first factor. |

## Errors

| HTTP | code | When |
|---|---|---|
| 400 | `unknown_field` | The body contains an unknown field. |
| 404 | `not_found` | The connection does not exist or belongs to another environment. |
| 409 | `rp_id_migration_in_progress` | Another rpId migration is running in this environment. |
| 412 | `precondition_failed` | `If-Match` does not match the current version. |
| 422 | `validation_failed` | A field is missing, too long or out of range; `new_rp_id` is not an `active` custom domain of the environment or equals the current rpId; `confirm_rp_id` differs; or the connection is not a passkey connection (`errors[].pointer`: `/new_rp_id`, `/confirm_rp_id`, `/migration_window_days`, `/connection_id`). |
| 428 | `precondition_required` | The `If-Match` header is missing. |
| 401 | `unauthenticated` | The credential is missing, invalid or revoked. |
| 403 | `insufficient_permissions` | The credential lacks the required permission. |
| 429 | `rate_limited` | The rate limit was exceeded; retry after the `Retry-After` period. |
| 503 | `service_unavailable` | A dependency is temporarily unavailable; retry after the `Retry-After` period. |

## Example

```bash
curl -X POST "https://api.eu.keenauth.com/v1/connections/conn_01j9xp6q0r1s5t7v9w1x3y5z7b:change-rp-id" \
  -H "Authorization: Bearer $KEENAUTH_SECRET_KEY" \
  -H 'If-Match: "3"' \
  -H "Content-Type: application/json" \
  -d '{"new_rp_id":"login.example.com","confirm_rp_id":"login.example.com"}'
```

```bash
keenauth connections change-rp-id conn_01j9xp6q0r1s5t7v9w1x3y5z7b --if-match '"3"' --yes \
  --data '{"new_rp_id":"login.example.com","confirm_rp_id":"login.example.com"}'
```
