Skip to content

SDKs

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
go get terriblegit.com/terrible/forge-go

Construct a client with an API key, then call a resource. This lists your sites — a sites:read call — iterating every page:

main.go
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:

build-and-follow.go
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:

configured-client.go
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:

webhook-handler.go
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:

client_test.go
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
npm install @managed/forge

Requires 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:

list-sites.ts
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:

create-and-wait.ts
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:

configured-client.ts
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:

errors.ts
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:

pagination.ts
const page = await mf.sites.list({ limit: 50 });
page.items; // this page's rows
page.hasMore; // whether another page exists
await page.next(); // the next Page, or null
await page.all(); // every remaining page, flattened into one array
for await (const site of page) { /* every item across all pages */ }

verifyWebhookSignature checks a Forge-Signature header — HMAC and timestamp — against the raw request body:

webhook-handler.ts
import { verifyWebhookSignature } from "@managed/forge";
// pass the RAW body, before JSON.parse
if (!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.