@afterpack/core and every framework plugin already speak this protocol once a Pro key is configured: auth, multipart streaming, retries, all of it. Read this when wiring a custom CI integration, writing a client outside the official packages, or debugging a response your tooling did not expect.
Base URL & versioning
Base URL: https://api.afterpack.dev/v1/
The API is URL-versioned and v1 is the only version. Within it, response fields are added, never removed or repurposed; a breaking change would ship as v2 alongside it. GET /v1/version returns the deployed version and environment, and GET / is an unauthenticated liveness check.
There is no client-version handshake.
Authentication
Two credentials, and they are not interchangeable.
| Credential | Header | What it reaches |
|---|---|---|
| A Pro API key | Authorization: Bearer ap_v1_<...> | POST /v1/obfuscate/batch and GET /v1/usage. The key derives its own workspace, so no workspace id is sent. |
| A dashboard session | The session your browser holds after signing in | Everything that manages an account: creating, listing and revoking keys, plans, billing, members, projects. Takes an explicit workspaceId and a workspace role. |
A key is a build credential. It obfuscates and reports its own usage, and it cannot mint another key, read billing, or change a workspace. That containment is the point: a key sitting in CI can spend your allowance, and nothing else.
Keys are ap_v1_ followed by 40 base62 characters. A key is shown exactly once, at creation. Only a salted HMAC-SHA256 hash is stored server-side, so a raw key is never recoverable later, only revocable. Mint one in the dashboard.
Endpoints
POST /v1/obfuscate/batch
The only obfuscation endpoint. One call is one build. Results stream back as each file finishes.
Request: multipart/form-data.
| Part | Required | Description |
|---|---|---|
batch | Yes | The batch manifest, as a JSON string part. |
| one part per file | Yes | The JS source, under the partName its manifest entry declares. |
| one part per source map | Optional | An upstream source map, under that entry's sourceMapPartName. |
The manifest is:
{
"version": "1",
"files": [
{
"filePath": "assets/app-a1b2c3.js", // echoed back on the result part
"partName": "file0", // names the form part carrying the source
"sourceMapPartName": "map0", // optional
"config": { "regions": [ /* ... */ ] } // optional, per-file
}
],
"config": { "preset": "hard", "seed": 42 }, // batch-level EngineConfig
"git": { "commitSha": "…", "ref": "…" } // optional build attribution
}The batch-level config is an engine config object: every key on Configuration, including preset, seed and regions. filePath and git are manifest-level fields; see the manifest shape above.
filePath may not contain a quote or a CR/LF character. It is echoed verbatim into the result part's Content-Disposition, and a malformed path is rejected.
curl https://api.afterpack.dev/v1/obfuscate/batch \
-H "Authorization: Bearer ap_v1_xxx" \
-F 'batch={"version":"1","files":[{"filePath":"dist/app.js","partName":"file0"}],"config":{"preset":"hard"}}' \
-F file0=@dist/app.jsResponse: 200 with a streaming multipart/mixed body. Parts arrive as files finish, so write each one to disk as it lands rather than buffering the batch:
- One
obfuscation-resultpart per file:{ version, filePath, status, output, outputMap, processingManifest }. On a failureoutputandoutputMaparenullandprocessingManifest.diagnosticssays why. - A final
batch-summarypart:{ version, totalFiles, successCount, failureCount, engineVersion, results, totalDurationMs, totalInputBytes, totalOutputBytes }.
A single file's failure does not abort the batch. Every other file still streams normally, and the summary reports the split.
GET /v1/usage
Workspace usage and allowance. The one management-shaped read a key can do, so a CI job can check its own remaining allowance. Accepts either a Pro API key (which derives its own workspace) or a dashboard session plus ?workspaceId=.
{
"tier": "indie",
"workspace": { "id": "…", "name": "…" },
"allowance": { "remainingMb": 412.5, "totalMb": 500, "resetAt": "…" },
"credits": { "balanceMb": 20 },
"subscription": { "status": "active", "plan": "indie_monthly", "renewsAt": "…", "graceUntil": null, "lapsed": false },
"recentBuilds": [
{
"buildId": "…", "builtAt": "…", "fileCount": 61,
"totalBytes": 3145728, "outputBytes": 9437184,
"meteredMb": 3.0, "allowanceMb": 3.0, "creditMb": 0, "overageMb": 0,
"projectId": "…", "cloudMs": 39120
}
]
}outputBytes and cloudMs are null when unknown, never 0 as a stand-in. allowanceMb / creditMb / overageMb are the split of meteredMb across the three pools.
Limits
| Limit | Value |
|---|---|
| Per file | 50 MB |
| Files per batch | 200 |
| Total batch bytes | 500 MB |
Over any of these, the request is rejected wholesale with 413 and DIAG_FILE_TOO_LARGE or DIAG_BATCH_TOO_LARGE, before anything is metered. The same numbers, from the dashboard's side, are in API keys.
Rate limits
Counted over a rolling 60-second window, per key and per client IP:
| Scope | Limit |
|---|---|
| Per key, free workspace | 10 requests / min |
| Per key, paid workspace | 100 requests / min |
| Per client IP | 100 requests / min |
Exceeding either scope returns 429 with DIAG_RATE_LIMITED and a Retry-After header set to the time until the triggering window actually drains. If the rate-limit check itself cannot run, the request fails closed with 503 DIAG_RATE_CHECK_UNAVAILABLE and Retry-After: 5.
The build time budget
A whole bundle is obfuscated inside one invocation, under a 120-second CPU limit. Two wall-clock budgets keep a build within that limit.
| Elapsed | What happens |
|---|---|
| 45 s | Protection Map capture stops. Obfuscation continues to completion; the stored map is marked partial with a deadline skip reason. |
| 90 s | No further file is started. Every remaining file streams an explicit failure with DIAG_BUILD_DEADLINE naming how many of N completed, and the stream closes normally. |
If you hit the second budget, split the batch.
Diagnostic codes
Errors carry a stable code alongside the HTTP status, the same DIAG_* namespace as the engine's own diagnostics. The ones a client has to handle:
| Code | Status | Meaning |
|---|---|---|
DIAG_INVALID_KEY | 401 | Missing or unrecognised key. |
DIAG_KEY_REVOKED | 401 | The key was revoked. |
DIAG_KEY_EXPIRED | 401 | The key is past its expiresAt. |
DIAG_KEY_OUT_OF_SCOPE | 403 | Valid key, used from an IP outside its allowedIPs list. |
DIAG_ENTITLEMENT_LAPSED | 402 | Subscription payment past due. Carries graceUntil and upgradeUrl. |
DIAG_QUOTA_EXHAUSTED | 402 | Allowance + credits (+ overage cap, if enabled) can't cover this build. Carries remainingMb, requestedMb and a reason distinguishing "out of quota" from "overage cap reached". |
DIAG_RATE_LIMITED | 429 | See rate limits above. Carries retryAfterSeconds. |
DIAG_RATE_CHECK_UNAVAILABLE | 503 | Rate-limit check failed; failing closed. Retry. |
DIAG_INVALID_MANIFEST | 400 | Malformed multipart body, missing batch part, or a manifest that fails validation. |
DIAG_FILE_TOO_LARGE | 413 | One file over 50 MB. |
DIAG_BATCH_TOO_LARGE | 413 | Over 200 files or 500 MB total. |
DIAG_BUILD_DEADLINE | n/a | Per-file, inside a 200 stream. The build ran out of time budget before this file was started. |
DIAG_ENGINE_FAILURE | n/a | Per-file, inside a 200 stream. The engine threw on this file. |
The last two are not HTTP statuses. They arrive as status: "failure" on an obfuscation-result part inside a successful response. A client that only inspects the HTTP status will mistake a partly-failed build for a clean one.
Retries & backoff
| Status | Retryable | Behaviour |
|---|---|---|
400, 401, 402, 403, 413 | No | Fix the request. |
429 | Once | Honor Retry-After exactly, capped at 120 s; a second 429 fails closed. |
500 | No | Engine bug; report it. |
503, 504 | Yes | 1 s, 4 s, 16 s (three retries, four attempts total), then fail closed. |
The official client also applies a 120-second whole-request timeout, and treats DNS, TLS and connection-reset failures as unreachable-cloud rather than retrying them indefinitely.
Key lifecycle, from a client's side
Minting, scoping and revoking are dashboard operations. Two behaviours matter to code calling this API:
- Mid-build revocation. A key revoked while a batch streams returns
401only on the next request; anything already in flight completes normally. - A key restricted to an IP list returns
403 DIAG_KEY_OUT_OF_SCOPEwhen used from anywhere else, distinct from the401an invalid or revoked key gets, so a client can tell "wrong key" from "right key, wrong place".
A batch that does not fit the workspace's pools is rejected wholesale before any file is dispatched, with the pools untouched: never partly processed, never partly billed. That is the batch-level form of the fail-closed rule.
Source-code handling
Source sent for obfuscation is processed in memory only, for the duration of that one request, and is never written to disk, logs, or a search index. Logs carry request ids, file counts, byte totals and diagnostic codes, never file contents, identifier names, or constant values.
One artifact outlives the request: the Protection Map stored per build for the dashboard. It contains your original source, coloured by how hard each token is to reverse. It is on by default per project, readable only by members of the owning workspace, retained one year and the last 500 builds per project (whichever runs out first), and switchable off in project settings. Full detail in Privacy & data handling.
Next
- Deployment modes: where this protocol fits relative to a local Free build.
- Pro: what Pro adds on top of the local engine.
- Privacy & data handling: what is stored, for how long, and by whom it is readable.
- Tiers: MB allowances, overage, and what each plan unlocks against this API.
- API keys: minting, scoping, rotating and revoking the credential this API authenticates.
- CLI reference: the official client that implements these rules.
- Configuration: the engine config the
configfield carries.