Skip to main content

Automation recipes

End-to-end examples against the Management API. Each assumes you have a token in $TOKEN and the two environment variables below — if not, start with API keys.

export API=https://api.phasetwo.io/v2
export TOKEN="$(curl -s -X POST \
https://app.phasetwo.io/auth/realms/self/protocol/openid-connect/token \
-d grant_type=client_credentials \
-d client_id="$PHASETWO_CLIENT_ID" \
-d client_secret="$PHASETWO_CLIENT_SECRET" | jq -r .access_token)"

All examples use curl and jq for legibility. In real automation, reach for the Terraform provider for anything resource-shaped — these recipes are the imperative equivalent, useful when you need control Terraform does not give you.


1. Provision a cluster

The full path from nothing to a usable Keycloak.

Pick an organization and a payment method

ORG_ID=$(curl -s $API/orgs -H "Authorization: Bearer $TOKEN" \
| jq -r '.[] | select(.name=="acme") | .id')

PM_ID=$(curl -s $API/orgs/$ORG_ID/billing/payment-methods \
-H "Authorization: Bearer $TOKEN" | jq -r '.[] | select(.is_default) | .id')

A saved payment method matters: supply payment_method_id and the subscription is charged directly. Omit it and the response is a Stripe Checkout link instead, which needs a browser and cannot be completed from a script.

Check the name and region

Cluster names are globally unique and become part of the default hostname, so check before you commit to one:

curl -s "$API/clusters/name-available?name=acme-prod" \
-H "Authorization: Bearer $TOKEN" | jq
# { "name": "acme-prod", "available": true }

curl -s $API/clusters/regions -H "Authorization: Bearer $TOKEN" | jq -r '.[].name'

Create it

CLUSTER=$(curl -s -X POST $API/clusters \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d @- <<JSON
{
"name": "acme-prod",
"region": "US_EAST_1",
"tier": "premium",
"billing_period": "MONTHLY",
"org_id": "$ORG_ID",
"payment_method_id": "$PM_ID"
}
JSON
)
CLUSTER_ID=$(echo "$CLUSTER" | jq -r '.cluster.id // .cluster_id')

The response has three shapes, and which one you get depends on the payment method:

ResponseMeaning
cluster setCharged and created. This is the scriptable path
link setNo payment method given — open the Stripe Checkout URL in a browser
requires_action: trueThe card needs 3-D Secure. Complete client_secret with Stripe.js

Only the first is automatable end to end. Handle the others by failing loudly rather than silently doing nothing.

Wait for ACTIVE

Creation returns before the cluster exists. Provisioning takes a few minutes:

until [ "$(curl -s $API/clusters/$CLUSTER_ID \
-H "Authorization: Bearer $TOKEN" | jq -r .status)" = "ACTIVE" ]; do
sleep 20
done
HOST=$(curl -s $API/clusters/$CLUSTER_ID -H "Authorization: Bearer $TOKEN" | jq -r .host)
echo "cluster up at $HOST"

States you may pass through: BILLING_SETUPPENDING_PAYMENTPROVISIONINGACTIVE. SETUP_EXCEPTION means it failed — stop and look, do not retry blindly.


2. Onboard a tenant with its own realm

The common multi-tenant pattern: one realm per customer, created on demand.

curl -s -X POST $API/clusters/$CLUSTER_ID/deployments \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"tenant-acme"}'

The name becomes the Keycloak realm name — max 255 characters, lowercased automatically, and rejected if reserved. Check first with GET /clusters/$CLUSTER_ID/deployments/name-available?name=... if the name is user-supplied.

Realm count is capped by tier (starter 5, premium 20, enterprise 100); exceeding it returns 409.

Then configure it with Keycloak's own API

The realm arrives empty. Everything inside it — clients, identity providers, users — is the realm's own API, on the cluster's host:

# a link that signs an admin straight into the new realm's console
curl -s -X POST $API/deployments/$DEPLOYMENT_ID/console-link \
-H "Authorization: Bearer $TOKEN" | jq -r .link

# or a token scoped to that deployment, for driving its Admin REST API
curl -s -X POST $API/deployments/$DEPLOYMENT_ID/token \
-H "Authorization: Bearer $TOKEN" | jq -r '.access_token, .base_url'

That token plus base_url is the handoff point between the two APIs: from here you are talking to Keycloak, using the Extensions API for organizations and SCIM and Keycloak's own admin API for the rest.

Import an existing realm instead

To migrate a realm you already have, post its export as multipart:

curl -s -X POST $API/clusters/$CLUSTER_ID/deployments/import \
-H "Authorization: Bearer $TOKEN" \
-F file=@realm-export.json \
-F deployment-name=tenant-acme

The import is asynchronous — poll the deployment until it leaves PENDING. Users are not supported in the file; an export containing a users array is rejected. Migrate users separately.


3. Attach a custom domain

Three steps, because DNS is involved and you own that part.

DOMAIN=$(curl -s -X POST $API/clusters/$CLUSTER_ID/domains \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"host":"auth.acme.com"}')
DOMAIN_ID=$(echo "$DOMAIN" | jq -r .domain.id)

Create the DNS records it returns, at your DNS provider:

curl -s $API/clusters/$CLUSTER_ID/domains/$DOMAIN_ID/status \
-H "Authorization: Bearer $TOKEN" | jq '.domain.domain_records'

Then wait for the certificate, and only then promote it:

until [ "$(curl -s $API/clusters/$CLUSTER_ID/domains/$DOMAIN_ID/status \
-H "Authorization: Bearer $TOKEN" | jq -r .certificate_status)" = "ISSUED" ]; do
sleep 60
done

curl -s -X PUT $API/clusters/$CLUSTER_ID/host \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"host":"auth.acme.com"}'

Order matters. Promoting before the certificate is issued returns 409, and the promotion also probes the host for reachability before switching — so a domain that does not resolve yet is refused rather than half-applied.


4. Lock down admin access by IP

curl -s -X POST $API/clusters/$CLUSTER_ID/ip-rules \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d @- <<'JSON'
{
"adminAllowedIpRules": [
{ "alias": "office", "address": "203.0.113.0/24" },
{ "alias": "ci", "address": "198.51.100.7/32" }
]
}
JSON
This replaces the whole category

Each category you include becomes the complete set — rules you omit are removed. To add one rule, read the current list, append, and send the whole thing. Omit a category entirely to leave it untouched.

Locking admin access to an allowlist that does not include you locks you out of the admin console. Include your own egress IP.

Rule counts are capped by tier (premium 2, enterprise unlimited, starter none).


5. Set an environment variable (and handle the restart)

Custom SPI configuration. Names must not start with KC_ unless they start with KC_SPI_:

curl -s -X POST $API/clusters/$CLUSTER_ID/env-vars \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"KC_SPI_MY_PROVIDER_TIMEOUT","value":"30","type":"STRING"}'

This restarts Keycloak. The API rejects a second restart-inducing change while one is in flight, with a 409. To apply several, serialize them:

wait_for_restart() {
while [ "$(curl -s $API/clusters/$CLUSTER_ID/restart-status \
-H "Authorization: Bearer $TOKEN" | jq -r .restart_in_progress)" = "true" ]; do
sleep 15
done
}

for kv in "KC_SPI_A=1" "KC_SPI_B=2"; do
wait_for_restart
curl -s -X POST $API/clusters/$CLUSTER_ID/env-vars \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d "{\"name\":\"${kv%%=*}\",\"value\":\"${kv#*=}\",\"type\":\"STRING\"}"
done

A SECRET-typed variable's value cannot be read back — reads return a mask — so keep the source of truth wherever you generated it.


6. Rotate an API secret with no downtime

The two credentials coexist, so overlap them:

NEW=$(curl -s -X POST $API/orgs/$ORG_ID/secrets \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"ci-2026-09","roles":["manage-clusters","view-organization"]}')

echo "$NEW" | jq -r '.client_id, .client_secret' # secret is shown ONLY here

Deploy the new pair, confirm traffic has moved, then revoke the old:

curl -s -X DELETE $API/orgs/$ORG_ID/secrets/$OLD_SECRET_ID \
-H "Authorization: Bearer $TOKEN"

Already-issued tokens survive until they expire — revocation is not retroactive. Cap of 10 secrets per organization, so clean up as you go.


7. Pull logs

curl -s "$API/clusters/$CLUSTER_ID/logs?from=2026-09-01T00:00:00Z&max=20" \
-H "Authorization: Bearer $TOKEN" | jq -r '.[].file_name'

The listing has no download URLs. Fetch one file at a time to get a short-lived presigned URL:

URL=$(curl -s "$API/clusters/$CLUSTER_ID/logs/$(printf %s "$FILE" | jq -sRr @uri)?minutes=10" \
-H "Authorization: Bearer $TOKEN" | jq -r .temp_url)
curl -s "$URL" | gunzip | head

Log paths contain slashes, so URL-encode the path segment — that is what the jq -sRr @uri is doing.


Error handling worth building in

StatusMeaningWhat to do
401Token expired or absentRe-exchange and retry once
403Secret lacks the organization roleFix the roles; do not retry
409Conflict — restart in flight, name taken, tier cap, wrong stateBack off and poll, or fail with the message
400ValidationRead the body; it names the field

409 is the one worth special-casing: it is the API telling you not yet rather than no, and the body distinguishes the cases.