Typed everywhere
Every resource, parameter, and the response envelope
are typed. Twenty-one services — Sites, Environments, Deployments,
Jobs, Webhooks, and the rest — mirror the API’s resource areas.
The official Go SDK gives you a typed, idiomatic client for the
managed.dev API. Its wire types are generated
from the same OpenAPI spec that defines the API —
a CI drift gate regenerates them and fails the build if the SDK and spec ever
disagree — with a curated, hand-written surface on top. The
mf CLI is built on it, so the SDK is exercised by every
CLI release.
The TypeScript SDK — @managed/forge — ships the same shape
off the same spec; jump to it below. PHP and Python SDKs are still
on the roadmap.
The SDK is a thin, typed wrapper over https://api.managed.dev/v1 — not a
second product with its own concepts. Everything you can do with cURL you can
do with the SDK, plus the ergonomics you’d expect from a PaaS client:
Typed everywhere
Every resource, parameter, and the response envelope
are typed. Twenty-one services — Sites, Environments, Deployments,
Jobs, Webhooks, and the rest — mirror the API’s resource areas.
Auto-pagination
Lists return Page[T] — range .Iter(ctx) or call .All(ctx) and
cursor pagination is handled for you, page by
page as you go.
Idempotent retries
The client attaches an Idempotency-Key to
every POST and retries 429/5xx/transport failures with exponential
backoff, honoring Retry-After — a retry replays the original job instead
of creating a second one.
Typed errors
The error envelope surfaces as *forge.Error
carrying Type, Code, Param, RequestID, and DocURL, with
predicates like NotFound() and RateLimited() — match on the code,
don’t parse strings.
Job helpers
Jobs.Wait, WaitSuccess, Stream, and Follow take an
async job to a terminal state — Follow
streams over SSE with a polling fallback, and the Success variants
return a typed *forge.JobError when the job fails.
Pinned version
The client sends Forge-Version: 2026-06-23 — the
API version it was built against — so an
additive API change never silently shifts a payload under you.
It also ships forge.VerifyWebhookSignature for
webhook verification and a forgetest package —
an in-process fake API server for your tests, with self-advancing jobs and
canned errors. Dependencies: the standard library plus google/uuid, nothing
else.
go get terriblegit.com/terrible/forge-goConstruct a client with an API key, then call a
resource. This lists your sites — a sites:read call — iterating every page:
package main
import ( "context" "fmt" "os"
forge "terriblegit.com/terrible/forge-go")
func main() { ctx := context.Background() client := forge.New(os.Getenv("FORGE_TOKEN"))
page, err := client.Sites.List(ctx, nil) if err != nil { if apiErr, ok := forge.AsError(err); ok { fmt.Fprintf(os.Stderr, "api error %s (%s): %s\n", apiErr.Code, apiErr.Type, apiErr.Message) os.Exit(1) } panic(err) }
// Iter fetches each next page lazily as you range. for site, err := range page.Iter(ctx) { if err != nil { panic(err) } fmt.Printf("%s %s %s\n", site.ID, site.Domain, site.Status) }}Most mutations return 202 Accepted and a *forge.Job.
Jobs.FollowSuccess streams progress to a writer and returns a typed
*forge.JobError if the job ends in failed; Jobs.WaitSuccess does the same
without the output stream. This kicks off a build and follows it:
job, err := client.Deployments.CreateBuild(ctx, siteID, envID, forge.BuildCreateParams{ Builder: "wordpress",})if err != nil { return err}
done, err := client.Jobs.FollowSuccess(ctx, job.ID, os.Stdout)if err != nil { var jobErr *forge.JobError if errors.As(err, &jobErr) { return fmt.Errorf("build failed: %s", jobErr.Job.Error.Message) } return err}fmt.Printf("build %s: %s\n", done.ID, done.Status)Job statuses are exactly queued, running, succeeded, and failed;
forge.JobTerminal(job) tells you whether one is done.
Options are functional and composable:
client := forge.New(os.Getenv("FORGE_TOKEN"), forge.WithAPIVersion("2026-06-23"), forge.WithMaxRetries(3), // default 2 forge.WithUserAgent("myapp/1.0"), forge.WithBaseURL("https://api.managed.dev/v1"),)forge.WithoutAutoIdempotency() disables the automatic Idempotency-Key; to
pin a specific key for one request, wrap the context with
forge.WithIdempotencyKey(ctx, key). The default request timeout is 30
seconds (streaming calls have none — cancel via the context), and the client
is safe for concurrent use.
forge.VerifyWebhookSignature checks a
Forge-Signature header — HMAC and timestamp —
in one call:
err := forge.VerifyWebhookSignature(payload, r.Header.Get("Forge-Signature"), os.Getenv("WEBHOOK_SECRET"), forge.DefaultWebhookTolerance)if err != nil { http.Error(w, "bad signature", http.StatusUnauthorized) return}forgetest spins up an in-process fake of the API so your tests don’t need a
network or a key:
srv := forgetest.NewServer(t)srv.Job("job_01J9F2KQ", "running", "succeeded") // self-advances on each poll
job, err := srv.Client.Backups.Create(ctx, "site_01J7QZ3M")done, err := srv.Client.Jobs.WaitSuccess(ctx, job.ID, nil)srv.Data, srv.List, and srv.Error stub canned responses for any route,
and srv.Requests() returns what your code actually sent.
The TypeScript SDK is @managed/forge —
a typed client for Node 18+ built on the global fetch, with no runtime
dependencies. Its wire types are generated from the same
OpenAPI spec as the Go SDK and gated against drift, so
the two stay in lockstep. It ships ESM and CommonJS with bundled type
declarations, and gives you the same ergonomics: envelope-aware responses, a pinned
Forge-Version, automatic
Idempotency-Key on POSTs, retries with backoff that
honor Retry-After, auto-paginating lists, job helpers,
typed errors, and webhook verification.
npm install @managed/forgeRequires Node 18 or newer for the global fetch.
Construct a Forge client with an API key, then reach the API
through typed resource services. This lists your sites, auto-paginating as you range:
import { Forge, ForgeError } from "@managed/forge";
const mf = new Forge(process.env.FORGE_TOKEN!);
try { for await (const site of await mf.sites.list()) { console.log(site.id, site.domain, site.status); }} catch (err) { if (err instanceof ForgeError) { console.error(`api error ${err.code} (${err.type}): ${err.message}`); process.exit(1); } throw err;}Most mutations return 202 Accepted and a job.
jobs.waitSuccess polls to a terminal state and throws a typed JobError if the job
ends failed; jobs.wait returns the job either way. This provisions a site and
waits for it:
import { Forge, ForgeError, JobError } from "@managed/forge";
const mf = new Forge(process.env.FORGE_TOKEN!);
try { const job = await mf.sites.create({ domain: "blog.example.com", profile: "bedrock", php_version: "8.3", git_repo: "git@github.com:acme/blog.git", }); const done = await mf.jobs.waitSuccess(job.id); console.log("provisioned:", done.resource);} catch (err) { if (err instanceof JobError) console.error("job failed:", err.job.error?.message); else if (err instanceof ForgeError) console.error(`API ${err.status}: ${err.message}`); else throw err;}Job statuses are exactly queued, running, succeeded, and failed;
jobTerminal(job) tells you whether one is done.
The second constructor argument is an options object:
const mf = new Forge(process.env.FORGE_TOKEN!, { apiVersion: "2026-06-23", // pin a dated Forge-Version maxRetries: 2, // 0 disables retries timeoutMs: 30_000, // per-request timeout baseURL: "https://api.managed.dev/v1", // override for staging userAgentSuffix: "myapp/1.0",});POSTs carry an auto-generated Idempotency-Key; pass { idempotencyKey } on a
call to pin your own, or disableAutoIdempotency: true to turn the behavior off.
Every method throws a ForgeError on a non-2xx response, carrying status, type,
code, param, requestId, and retryAfter, with boolean getters so you match on
the condition instead of parsing strings:
try { await mf.sites.get("missing");} catch (err) { if (err instanceof ForgeError && err.notFound) { // 404 — err.code is "site.not_found" }}Getters: notFound, unauthorized, forbidden, conflict, rateLimited,
quotaExceeded, and temporary.
Lists return a Page you can iterate lazily or drain in one call:
const page = await mf.sites.list({ limit: 50 });page.items; // this page's rowspage.hasMore; // whether another page existsawait page.next(); // the next Page, or nullawait page.all(); // every remaining page, flattened into one arrayfor await (const site of page) { /* every item across all pages */ }verifyWebhookSignature checks a Forge-Signature
header — HMAC and timestamp — against the raw request body:
import { verifyWebhookSignature } from "@managed/forge";
// pass the RAW body, before JSON.parseif (!verifyWebhookSignature(rawBody, req.headers["forge-signature"], process.env.WEBHOOK_SECRET!)) { res.statusCode = 400; return;}For an endpoint no typed service wraps yet, the escape hatch is
mf.http.requestData(...) / mf.http.requestList(...).
Preview The PHP and Python SDKs are designed to come off the same generation pipeline as the Go and TypeScript SDKs, with the same shape — auto-pagination, idempotent retries, typed errors, job helpers. They have not shipped; there is no package to install today.