Automate Everything: Using the Bunnyshell Public API to Manage Environments Programmatically
Why an API Matters Beyond the CLI
Everything demonstrated elsewhere in this pillar — creating, deploying, cloning, stopping, exec-ing into environments — has been shown through the bns CLI, because for scripts and CI jobs, the CLI is the simplest interface. But the CLI is itself a client of an underlying REST API, and for automation that doesn't live in a shell context — an internal dashboard that shows engineers their active environments, a Slack bot that responds to "spin me up a copy of staging," a custom internal developer portal — the API is the direct integration point.
This guide covers the authentication model and core operations, plus a real API response captured directly from a live environment.
Authentication
Every request needs a Bearer token in the Authorization header — the same Personal Access Token (or account-wide token) that authenticates the CLI:
Authorization: Bearer YOUR_API_TOKENBase URL for all endpoints:
https://api.environments.bunnyshell.com/v1As with CLI usage, a scoped Personal Access Token — limited to a specific organization, project, or even a single environment, with only the permissions the integration actually needs (view, manage, delete, create, operate) — is the recommended credential for any integration that will live somewhere persistent, like an internal tool's configuration or a CI secret store. An account-wide token can see every organization its owner belongs to, which means a misconfigured integration holding the wrong token doesn't fail loudly with an auth error — it can succeed quietly against the wrong organization's environments, a failure mode that's much harder to catch after the fact than an outright rejected request.
Core Operations
List environments in a project:
curl -X GET "https://api.environments.bunnyshell.com/v1/environments?project=PROJECT_ID" \
-H "Authorization: Bearer $TOKEN"Get a specific environment's current state:
curl -X GET "https://api.environments.bunnyshell.com/v1/environments/ENV_ID" \
-H "Authorization: Bearer $TOKEN"Create from a template:
1curl -X POST "https://api.environments.bunnyshell.com/v1/environments/from-template" \
2 -H "Authorization: Bearer $TOKEN" \
3 -H "Content-Type: application/json" \
4 -d '{
5 "name": "my-environment",
6 "project": "PROJECT_ID",
7 "template": "TEMPLATE_ID",
8 "kubernetesIntegration": "CLUSTER_ID"
9 }'Real Response Structure, Captured Live
Rather than reconstruct an idealized example, here is the actual, unmodified JSON body returned by a live GET /v1/environments/{id} call against a real deployed environment (captured via the CLI's own --debug request/response logging, which surfaces the raw API payload underneath the formatted table output):
1{
2 "id": "5bDn8MrEgm",
3 "labels": {},
4 "type": "primary",
5 "name": "content-demo",
6 "namespace": "jfq6ir",
7 "urlHandle": "jfq6ir",
8 "totalComponents": 1,
9 "operationStatus": "running",
10 "clusterStatus": "running_ok",
11 "buildSettings": null,
12 "project": "2grgN6gaM9",
13 "kubernetesIntegration": "OY13OP4Gnr",
14 "hostingRegion": null,
15 "ephemeralKubernetesIntegration": null,
16 "ephemeralHostingRegion": null,
17 "hasTerminationProtection": false,
18 "hasAutoUpdate": false,
19 "hasRemoteDevelopmentAllowed": true,
20 "hasEphemeralAutoDeploy": false,
21 "hasEphemeralCreateOnPr": false,
22 "hasEphemeralDestroyOnPrClose": false,
23 "hasEphemeralBranchWhitelist": false,
24 "ephemeralBranchWhitelistRegex": null,
25 "templateSettings": {
26 "branchFallbackOnDelete": null,
27 "branchFallbackOnDeleteAutoDeploy": null
28 }
29}This is genuinely the field structure your integration will parse — note that the boolean settings governing PR automation (hasEphemeralAutoDeploy, hasEphemeralCreateOnPr, hasEphemeralDestroyOnPrClose) are exactly the same underlying fields the CLI's update-settings command sets, and the same ones covered conceptually in Automated PR Environments for E2E Testing — an internal dashboard that wants to show "which environments have PR automation enabled" reads these three fields directly, no separate lookup required.
Building an Internal Tool: A Concrete Example
A common first integration: a Slack slash-command or internal web form that lets an engineer clone and deploy a personal copy of staging without touching a CLI at all.
1import requests
2
3TOKEN = "YOUR_SCOPED_PAT"
4BASE = "https://api.environments.bunnyshell.com/v1"
5HEADERS = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}
6
7def clone_and_deploy(source_env_id: str, new_name: str) -> str:
8 clone_resp = requests.post(
9 f"{BASE}/environments/{source_env_id}/clone",
10 headers=HEADERS,
11 json={"name": new_name},
12 )
13 clone_resp.raise_for_status()
14 new_env_id = clone_resp.json()["id"]
15
16 requests.post(f"{BASE}/environments/{new_env_id}/deploy", headers=HEADERS).raise_for_status()
17 return new_env_idA Slack bot handler calling clone_and_deploy("staging-env-id", f"{user}-personal-copy") in response to a slash command gives engineers exactly the self-service capability described in Self-Service Staging Clones — but surfaced inside the tool they're already using (Slack) instead of requiring them to learn bns at all. This is the actual value of a direct API integration over the CLI: it lets you build the interface that fits your team's workflow, rather than the interface Bunnyshell ships by default.
Monitoring Pipeline Status Programmatically
Environment operations (deploy, clone, stop, start) return an event/pipeline reference, not an immediate final result — the same asynchronous pattern the CLI handles by polling when you don't pass --no-wait. An API integration needs to poll the pipeline endpoint the same way:
1import time
2
3def wait_for_pipeline(pipeline_id: str, timeout_s: int = 300) -> str:
4 deadline = time.time() + timeout_s
5 while time.time() < deadline:
6 resp = requests.get(f"{BASE}/pipelines/{pipeline_id}", headers=HEADERS)
7 status = resp.json()["status"]
8 if status in ("success", "failed"):
9 return status
10 time.sleep(5)
11 raise TimeoutError(f"Pipeline {pipeline_id} did not finish within {timeout_s}s")Rate Limits and Good Citizenship
As with any API powering both a CLI used by many engineers and direct integrations, be deliberate about polling frequency — a 5-second poll interval (as used above) is reasonable for a human-facing tool where a few extra seconds of latency in reporting "done" is invisible; a tight polling loop with no backoff is not good practice for a CI-scale integration triggering many concurrent operations. Prefer webhook-based notification where your use case supports it (see Webhook-Triggered Environment Updates for the equivalent pattern on the Git-integration side) over aggressive polling wherever the operation supports it.
Error Handling: What Actually Fails and How to Handle It
A production-grade integration needs to handle the specific ways these API calls fail, not just the happy path shown above. The most common failure categories:
Authentication failures (401) — an expired or revoked token, or a scoped PAT missing the permission the specific operation requires. These fail immediately, before any pipeline is even queued, so they should be caught and surfaced clearly rather than allowed to manifest as a confusing downstream timeout.
Validation failures (400/422) — a malformed bunnyshell.yaml reference, a nonexistent template or cluster ID, or (as demonstrated earlier in this pillar) an environment definition referencing an image tag that doesn't exist. These are caught at request time for structural issues, but issues like a bad image tag only surface once the deploy pipeline actually runs and fails — which is why polling for pipeline completion (shown above) and checking the specific job/step failure reason matters, rather than treating "the API call succeeded" as equivalent to "the operation succeeded."
1def deploy_and_check(env_id: str) -> dict:
2 resp = requests.post(f"{BASE}/environments/{env_id}/deploy", headers=HEADERS)
3 resp.raise_for_status()
4 pipeline_id = resp.json()["pipelineId"]
5 status = wait_for_pipeline(pipeline_id)
6 if status == "failed":
7 jobs_resp = requests.get(
8 f"{BASE}/pipelines/{pipeline_id}/jobs",
9 headers=HEADERS, params={"jobStatus": "failed"},
10 )
11 return {"status": "failed", "failed_jobs": jobs_resp.json()}
12 return {"status": "success"}Rate limiting (429) — relevant for any integration triggering a high volume of operations in a short window (a CI system creating many concurrent ephemeral environments, for instance). Implement exponential backoff on a 429 response rather than retrying immediately, and consider whether the integration's actual need justifies the request volume or whether it should be restructured to poll less aggressively or batch operations.
A Webhook Receiver for Pipeline Completion, Instead of Polling
The polling pattern shown earlier is simple but not the most efficient approach for an integration that needs to react to many concurrent operations — a webhook-based notification, where Bunnyshell calls your integration's endpoint when a pipeline completes, avoids the overhead of repeated polling entirely:
1from flask import Flask, request
2
3app = Flask(__name__)
4
5@app.route("/bunnyshell-webhook", methods=["POST"])
6def handle_pipeline_event():
7 payload = request.json
8 if payload["event"] == "pipeline.completed":
9 pipeline_id = payload["pipelineId"]
10 status = payload["status"]
11 # notify Slack, update an internal dashboard, trigger the next step, etc.
12 return "", 200This mirrors the same webhook-driven pattern covered in Webhook-Triggered Environment Updates for the Git-integration side — the API supports configuring a callback URL to notify on pipeline state changes, which is the more scalable choice than polling for any integration expected to handle meaningful concurrent volume.
Versioning: Building Against a Stable Contract
Like any REST API intended for third-party integration, worth checking whether your automation should pin to a specific API version rather than always hitting the latest, particularly for an integration that will run unattended for a long time without active maintenance attention. The /v1 prefix in the base URL is itself the version marker — a future breaking change would be introduced as a new version prefix rather than a silent change to /v1's existing behavior, which is the standard REST API versioning practice and means an integration built against /v1 today should keep working unchanged even as new capabilities are added under later version paths. For an internal tool your team will maintain long-term (the Slack bot example above, say), it's still worth a periodic review of the API changelog rather than assuming indefinite silence means nothing relevant has changed — new optional fields or endpoints are usually additive and safe to ignore until needed, but it's good practice to know they exist.
Read-Only Integrations: A Lower-Risk Starting Point
For a first API integration, especially one built by someone newer to the platform, starting with read-only operations (listing environments, checking pipeline status, building a dashboard) before adding any write operation (create, deploy, delete) is a reasonable way to build confidence in the integration's correctness without any risk of an accidental destructive action from a bug in early-stage integration code. A read-only PAT (view permission only, no manage or delete) enforces this at the credential level, not just as a coding discipline — even a bug that attempts a delete call fails with a permissions error rather than actually deleting anything, which is a meaningfully safer way to develop and test a new integration than building the full read/write surface from day one and trusting the code to be bug-free before it's had any real-world exposure.
Building a Minimal Internal Dashboard
A common early API integration is a read-only internal dashboard showing every active environment across a team's projects — useful for a platform team wanting visibility without asking each engineer to run bns environments list themselves. A minimal version:
1import requests
2from flask import Flask, render_template_string
3
4app = Flask(__name__)
5TOKEN = "YOUR_READ_ONLY_PAT"
6BASE = "https://api.environments.bunnyshell.com/v1"
7
8@app.route("/")
9def dashboard():
10 resp = requests.get(f"{BASE}/environments", headers={"Authorization": f"Bearer {TOKEN}"})
11 environments = resp.json()["data"]
12 rows = "".join(
13 f"<tr><td>{e['name']}</td><td>{e['type']}</td><td>{e['operationStatus']}</td></tr>"
14 for e in environments
15 )
16 return render_template_string(f"<table><tr><th>Name</th><th>Type</th><th>Status</th></tr>{rows}</table>")This is deliberately minimal — a real internal tool would add filtering, sorting by age, and probably a button wired to the write operations shown earlier — but it demonstrates the actual starting point most teams' first integration looks like: a read-only view built in an afternoon, using a scoped PAT with only view permission, that immediately gives visibility a team didn't have without asking every engineer to check the CLI individually.
When the CLI Wraps the API More Conveniently Than Direct Calls Would
Before building a substantial custom integration against the raw API, it's worth a genuine gut-check about whether the CLI (covered throughout this pillar, particularly in Managing Environment Lifecycle via CLI) already does what's needed with less code to maintain. The CLI is itself a well-tested client of this same API, handling authentication, pagination, and polling-for-completion patterns that a from-scratch integration would otherwise need to reimplement and keep correct over time. The API is the right choice specifically when the automation needs to live somewhere a shell command doesn't naturally fit — a web dashboard, a chat bot, an event-driven serverless function reacting to some other system's trigger — not as a default first choice over the CLI for anything that could just as easily be a script. Reaching for the API out of habit, for something a two-line bns script would have handled, is unnecessary maintenance surface for no real benefit.
The Underlying Point
Every guide in this pillar has shown one interface into the same underlying system — the CLI, PR comments, or the raw API — because the right interface depends entirely on where the automation needs to live, not on any interface being more "official" than another. Choosing the API specifically means choosing to build something that lives outside a shell or a Git provider's comment thread, and that choice should be made deliberately, for a real integration need, rather than by default.
Related Reading
For the CLI-based equivalent of everything demonstrated here, see Managing Environment Lifecycle via CLI. For the encrypted-secrets handling your integration will need if it provisions credentials programmatically, see Encrypting and Decrypting Secrets via CLI — the same SECRET[...]/ENCRYPTED[...] mechanics apply whether the value originates from a CLI session or, as shown above, from a script calling the API directly.
Checklist
- Integration uses a scoped PAT (organization, project, or environment-level), not an account-wide token
- Async operations (deploy, clone, stop, start) are polled for completion, not assumed to be synchronous
- Polling intervals are reasonable for the use case — human-facing tools can be relaxed, high-frequency automation should back off
- The credential is stored in a proper secret store for the integration's environment, not hardcoded
Build the environment tooling your team actually wants.
14-day full-feature trial. No credit card required. Pay-as-you-go from $0.007/min per environment.
Frequently Asked Questions
When should I use the API directly instead of the bns CLI?
When the automation lives outside a shell context — an internal dashboard, a Slack bot, a custom workflow engine, or any system that needs to call environment operations as part of a larger programmatic flow rather than a script or CI step. The CLI is generally simpler for anything that is itself a script or CI step.
What authentication does the API require?
A Bearer token in the Authorization header, the same Personal Access Token or account-wide token used to authenticate the bns CLI. Scoped PATs (organization, project, or environment level) are recommended for API integrations the same way they are recommended for CLI use, so an integration only has the access it actually needs.
Is the API's data model the same as what the CLI shows?
Yes — the CLI is itself a client of this same API, so the fields you see in bns commands (operationStatus, clusterStatus, hasTerminationProtection, and so on) are the actual underlying API resource fields, not a CLI-specific reformatting.
Can I create and deploy an environment entirely through the API without ever touching the CLI?
Yes, the same operations the CLI exposes (create from template or configuration, deploy, clone, stop, start, delete) are available as REST endpoints, since the CLI itself calls these endpoints under the hood.
What is the base URL for the API?
https://api.environments.bunnyshell.com/v1 — every environment, project, and pipeline operation is scoped under this base path.