Skip to content

Packdog API Reference

Base URL: https://api.packdog.dev/v1

Auth

There are four identities the API recognises:

  • Organization API key (load_...) — load access. Used by browser-side code to fetch the current version of a channel. Read-only and scoped to packages your organization owns. Lives in browser JS by design.
  • CI token (ci_...) — a CI/machine credential for upload and publish access. Scope is the set of projects the token has grants on (multi-project grants). A CI token can upload versions and publish or roll back the open channels of packages in those projects; publishing or rolling back a protected channel additionally requires that grant's canPublishProtected flag. Used from a server, a CI pipeline, or the packdog CLI with PACKDOG_TOKEN set. ci_ tokens are for machines — humans sign in instead.
  • Signed-in user (a signed-in session) — the human identity for /v1/me/* self-service. You sign in through your browser (packdog login on the CLI, or the web panel at app.packdog.dev); the session is sent as the Authorization: Bearer token. An admin can manage members and CI tokens, rotate the load key, view usage, and create + fully operate the packages the organization owns (upload, publish, rollback — including protected channels, with no grant to yourself). A member is scoped to the packages an admin has explicitly granted them. You join an organization by accepting an emailed invitation.
  • Operator — used by the Packdog operator for service management (creating organizations, rotating keys, deleting packages). Not customer-facing and intentionally not documented here. Email post@jetbit.no if you need an operator operation performed against your account.

Public endpoints (no auth): GET /health, GET /.


Pagination

The list endpoints that grow without bound — a package's versions, a channel's publish history, and the audit logs — are paginated with a keyset cursor, newest first. They return an envelope:

json
{
  "data": [ /* … rows, newest first … */ ],
  "nextCursor": "eyJ0Ijo…"
}
  • ?limit=N sets the page size (default 50; the audit endpoints default 100; capped at 1000).
  • ?before=<cursor> fetches the next page. Pass back the nextCursor from the previous response verbatim — it's an opaque token. When nextCursor is null, you've reached the end.

The cursor is stable under concurrent writes: new rows arriving at the head while you page don't cause skips or duplicates. To fetch an entire list, follow nextCursor until it's null (the packdog CLI's --all flag does this for you).

Bounded lists (channels, projects, CI tokens, members) are not paginated — they return a bare array.


Core concepts

Versions are immutable upload artifacts. Uploading always creates a new version with its own prefixed ID (ver_…). Versions know nothing about channels.

Channels are named pointers to a version (prod, test, beta — any name you choose). Each channel has its own independent rollback history.

A channel is an explicitly-created resource: an organization admin creates it before anything is published to it. Publishing never auto-creates a channel — publishing to a channel that doesn't exist returns 404, so a typo'd channel name is an error, not a new ghost channel. A freshly-created channel can sit empty (no version published yet); in channel listings its version_id is null. A package can hold at most 10 channels.

Projects are the org-internal sub-tenancy boundary. Every package belongs to exactly one project; CI tokens and members are granted at project level. Every organization starts with zero projects — the admin creates their first one explicitly before any package or CI token can be created (no auto-default). Single-project orgs use the layer trivially (one project, all packages go there); multi-project orgs use it to scope grants (e.g. "this CI token only publishes packages in the Marketing site project").

Channels are open by default — any CI token with a grant on the package's project / member with package- or project-grant can publish or roll them back. A channel can be marked protected; publishing or rolling back a protected channel requires the relevant grant's canPublishProtected = true (a CI token's grant for the package's project, or a member's per-package OR per-project grant — the UNION). The operator (operator token) bypasses protection; admins/owners bypass it on packages their org owns.

The typical flow:

deploy (upload + publish to a channel) → test → promote that version to another channel

Resource IDs follow a Stripe-style prefix scheme — pkg_ (package), prj_ (project), org_ (organization), ver_ (version), cit_ (CI token), log_ (audit log entry). The prefix is the type tag; the rest is a 26-char ULID (a millisecond timestamp + 80 random bits, Crockford base32), so IDs sort by creation time. Credentials use their own prefixes — ci_ for CI-token values (distinct from the cit_ row ID), load_ for organization load keys. IDs are opaque strings — never parse or reconstruct them.


Endpoints

GET /me

Identity for the authenticated caller. Dispatches on credential type.

Auth: CI token (ci_*) or a signed-in user (session).

Response (CI token):

json
{
  "kind": "ci_token",
  "id": "cit_…",
  "name": "github-actions-frontend",
  "projectAccess": [
    {
      "projectId": "prj_…",
      "projectName": "Marketing site",
      "canPublishProtected": false,
      "grantedAt": 1234567890
    }
  ],
  "packages": [
    { "id": "pkg_…", "name": "checkout-widget", "created_at": 1234567890, "project_id": "prj_…" }
  ]
}

projectAccess is the set of project grants this token holds — each grant unlocks every package in that project. packages is the resolved list of packages the token can act on, flattened from those grants.

Response (signed-in user):

json
{
  "kind": "user",
  "workosUserId": "user_01...",
  "role": "admin",
  "organization": {
    "id": "org_…",
    "name": "acme",
    "apiKeyPrefix": "load_a3f8e2c1",
    "createdAt": 1234567890,
    "maxPackages": 5,
    "packageCount": 2,
    "maxCiTokens": 50,
    "ciTokenCount": 3,
    "maxProjects": 20,
    "projectCount": 1
  }
}

role is your role in the organization — admin or member. maxPackages / maxCiTokens / maxProjects are the quotas set for your organization; packageCount / ciTokenCount / projectCount are how many you currently own. When a count reaches its quota, the matching create endpoint (POST /me/packages / POST /me/ci-tokens / POST /me/projects) returns 403 — contact us to raise it.


Self-service (/v1/me/*)

These endpoints require a signed-in user (a session — packdog login, or the web panel). Every operation is scoped to the caller's organization — cross-tenant access returns 404 so existence is never leaked. The endpoints below are the admin surface; a member-role user can reach only the packages explicitly granted to them.

GET /me/usage

Per-package load counts for your organization over the last ?days=30 (1–90, default 30).

Response:

json
{
  "organizationId": "org_…",
  "organizationName": "Acme Corp",
  "period": "last 30 days",
  "data": [
    { "packageId": "pkg_…", "packageName": "checkout-widget", "loads": 12345 }
  ]
}

GET /me/usage/summary

The org loads graph: a daily time-series plus a by-project breakdown, over the last ?days=30 (1–90, default 30). Admin role only. Powers the chart on the org Overview. Counts use the same load definition and weighting as billing, so the total here equals what you'd be invoiced.

Response:

json
{
  "period": "last 30 days",
  "total": 48210,
  "series": [{ "date": "2026-05-01 00:00:00", "loads": 1620 }],
  "byProject": [{ "projectId": "prj_…", "projectName": "Marketing site", "loads": 31050 }]
}

A package deleted within the window can't be attributed to a project anymore; its loads appear under a "(deleted)" project bucket (and still count in total).

GET /me/projects/{id}/usage

A project's loads graph: daily series + a byPackage breakdown ({ packageId, packageName, loads }), over ?days=. Admin role only. 404 if the project isn't in your organization. A project with no packages returns total: 0 and empty arrays.

GET /me/packages/{id}/usage

A package's loads graph: daily series + a byChannel breakdown ({ channel, loads }), over ?days=. Available to an admin on any package the org owns, and to a member on a package they've been granted. 404 if the package isn't owned/granted.

GET /me/audit

Audit log across every package your organization owns, newest first. Same row shape as GET /me/packages/{id}/audit (the per-package mirror), without needing to fan out across each owned package. Paginated (see Pagination) — ?limit= (default 100, capped 1000) + ?before=, returns { data, nextCursor }.

Pure organization / CI-token / token-management actions carry no package_id and are NOT included — those live in the operator's registry-wide audit log. For the customer's view, per-package coverage is what matters in practice.

Response:

json
{
  "data": [
    {
      "id": "log_…",
      "timestamp": 1234567890,
      "actorType": "user",
      "actorId": "user_01...",
      "actorName": "user_01...",
      "actorOrganizationId": null,
      "actorOrganizationName": null,
      "action": "channel.publish",
      "packageId": "pkg_…",
      "packageName": "checkout-widget",
      "packageExists": true,
      "projectId": "prj_…",
      "projectName": "Marketing site",
      "projectExists": true,
      "targetType": "channel",
      "targetId": "prod",
      "detail": { "versionId": "ver_…" }
    }
  ],
  "nextCursor": null
}

actorType is operator, ci_token, or user (a signed-in human); a user row's actorId is the user id. The web panel resolves that id to a real name when it renders the log. packageName / projectName are frozen at write time (they stay readable even after the resource is renamed or deleted); packageExists / projectExists say whether each still exists, so a client links only live resources.

GET /me/packages

List packages your organization owns.

Response:

json
[
  { "id": "pkg_…", "name": "checkout-widget", "created_at": 1234567890, "owner_organization_id": "org_…" }
]

For a member-role caller the list is scoped to their grants, each row carries project_name (a name is unique only within its project), and a can_publish_protected field (0/1) reports the grant's effective protected-publish capability — the UNION of the member's per-package and per-project grants.

GET /me/packages/lookup

Resolve a package name to its id, within the packages your organization owns. A package name is a handle — unique within a project — so a name addresses a package; this is how a client turns a name into the id the other endpoints take.

Query: ?name=<name> (required) and an optional ?project=<name|id> to qualify when the name exists in more than one project.

GET /v1/me/packages/lookup?name=checkout-widget
→ 200  { "packageId": "pkg_…", "name": "checkout-widget", "projectId": "prj_…" }
→ 404  no package with that name (in that project, if `project` was given)
→ 409  ambiguous — the name exists in more than one project; the body lists the
       candidates so you can qualify: { "error": "...", "candidates": [
         { "packageId": "pkg_…", "projectId": "prj_…", "projectName": "marketing" }, … ] }

A CI token has the same endpoint at GET /v1/packages/lookup, scoped to the packages in the projects it's granted on. The packdog CLI uses this transparently — -p <name> resolves through it before acting; a pkg_ id skips it.

POST /me/packages

Create a package owned by your organization. The new package counts against your organization's max_packages quota (see maxPackages / packageCount on GET /me).

Request:

json
{ "name": "checkout-widget", "projectId": "prj_…" }

Name rules (same slug rule as channels): lowercase letters, digits, hyphens; 1–63 chars; must start with a letter or digit. Matches /^[a-z0-9][a-z0-9-]{0,62}$/.

A package name is a handle — unique within its project. Creating a package with a name already used in the same project returns 409 (names may still repeat across different projects). Renaming onto a taken name (PATCH /me/packages/{id} with { name }) returns 409 the same way. The stable pkg_ id is unaffected by a rename, so loads never break.

projectId is required — there is no default project to fall back on. The project must belong to your organization (422 otherwise). The panel pre-fills this from the project the admin is currently viewing.

Response:

json
{ "packageId": "pkg_…", "name": "checkout-widget", "projectId": "prj_…", "message": "..." }
  • 400 Bad Request if the name fails the slug rule or projectId is missing on an org that already owns projects.
  • 403 Forbidden if your organization is already at its package quota — the message reads Package limit reached (N/N). Contact us to raise it.
  • 422 Unprocessable if the supplied projectId doesn't belong to your organization, or if your organization has zero projects (the message reads This organisation has no projects yet — create a project first (POST /v1/me/projects), then place the package in it.).

The operator endpoint POST /v1/packages still exists, but you no longer need it: an organization admin can self-serve package creation here, bounded by the operator-set quota.

GET /me/keys

List your organization's load keys — the current key and, during a rotation grace window, any retiring key. Admin-only. Response sets Cache-Control: no-store, private.

Response:

json
[
  { "id": "lk_…", "name": "default", "apiKeyPrefix": "load_a3f8e2c1", "key": "load_a3f8e2c1d4b5…", "createdAt": 0, "expiresAt": null },
  { "id": "lk_…", "name": "default", "apiKeyPrefix": "load_91bc77de", "key": null, "createdAt": 0, "expiresAt": 1780000000000 }
]

expiresAt: null is the current key; a timestamp marks a retiring key (valid until that instant). key is the full retrievable value — a load key is a publishable credential (it ships in your page source by design), so listing it back is intended. key: null marks a key issued before retrievability (2026-06-10); rotate to get one with a stored value.

POST /me/keys/rotate

Rotate your organization's load_* load key. Zero-downtime by default. Response sets Cache-Control: no-store, private.

Body (optional):

json
{ "mode": "grace", "graceHours": 168 }
  • mode: "grace" (default) — the previous key keeps working until previousKeyValidUntil (default 7 days, capped at 30; override with graceHours), so already-deployed pages don't break while you roll out the new key.
  • mode: "immediate" — the previous key is revoked at once (leak response).

Response:

json
{
  "organizationId": "org_…",
  "apiKey": "load_<new>",
  "apiKeyPrefix": "load_<first8>",
  "previousKeyValidUntil": 1780000000000,
  "mode": "grace",
  "message": "..."
}

previousKeyValidUntil is null for immediate mode (or when there was no prior key). The key stays retrievable afterwards via GET /me/keys (it's a publishable key — no shown-once handling).

DELETE /me/keys/{id}

Revoke a retiring load key early — end its grace window once you've rolled the new key out everywhere. Admin-only. Returns 404 if the id isn't a retiring key (the current key can't be revoked this way — rotate instead).

PATCH /me/organization

Rename your own organization. Admin role only (a member gets 403). You own your organization's name — the operator no longer "labels" it for you; the stable organization ID never changes.

Request:

json
{ "name": "Acme Corp" }

Response:

json
{
  "organizationId": "org_...",
  "name": "Acme Corp",
  "previousName": "Old Name",
  "message": "Organization renamed to 'Acme Corp'"
}

name is a display name — unicode, spaces and emoji are allowed; it is trimmed and capped at 100 characters, and rejected (400) if empty or containing control characters.

GET /me/ci-tokens

List CI tokens your organization owns.

Response:

json
[
  {
    "id": "cit_…",
    "name": "github-actions",
    "tokenPrefix": "ci_a3f8e2c1",
    "createdAt": 1234567890,
    "createdByWorkosUserId": "user_01..."
  }
]

createdByWorkosUserId is the user id of the human who created the token (null for operator-created tokens). The web panel resolves it to a real name at render time. The list view does not expand per-grant project access — fetch the detail endpoint for that.

POST /me/ci-tokens

Create a CI token owned by your organization.

Request:

json
{
  "name": "github-actions",
  "projectAccess": [
    { "projectId": "prj_…", "canPublishProtected": false }
  ]
}

name is a free-text label shown in the audit log. Name it after the project it's scoped to or the pipeline that uses it (Marketing site CI, nightly-build), not a single package — a token can publish every package in its project. projectAccess is required — at least one grant. Each grant unlocks every package in that project; canPublishProtected: true additionally allows publishing to and rolling back the project's protected channels. If your organization has zero projects the Worker returns 422 with a guiding message — create a project first via POST /v1/me/projects.

Response (Cache-Control: no-store):

json
{
  "ciTokenId": "cit_…",
  "name": "github-actions",
  "token": "ci_<plaintext>",
  "tokenPrefix": "ci_<first8>",
  "ownerOrganizationId": "org_…",
  "projectAccess": [
    { "projectId": "prj_…", "canPublishProtected": false }
  ],
  "message": "..."
}

The token is shown once. Paste it into your CI's secrets manager as PACKDOG_TOKEN.

Returns 403 if your organization is at its maxCiTokens quota (CI token limit reached (N/N)) — contact us to raise it.

GET /me/ci-tokens/{id}

Single CI token, with projectAccess expanded to include each grant's resolved project name:

json
{
  "id": "cit_…",
  "name": "github-actions",
  "tokenPrefix": "ci_<first8>",
  "createdAt": 1234567890,
  "createdByWorkosUserId": "user_01...",
  "projectAccess": [
    {
      "projectId": "prj_…",
      "projectName": "Marketing site",
      "canPublishProtected": false,
      "grantedAt": 1234567890
    }
  ]
}

404 if the CI token belongs to another organization.

PATCH /me/ci-tokens/{id}

Rename a CI token. Protected publishing is managed per-project via the /project-access/* endpoints below.

Request:

json
{ "name": "new label" }

The rename leaves the token value and scope untouched — it just updates the audit-log / panel label. 404 if the CI token belongs to another organization.

POST /me/ci-tokens/{id}/project-access

Grant a CI token access to one more project.

Request:

json
{ "projectId": "prj_…", "canPublishProtected": false }

Idempotent — granting an already-granted project is treated as an upsert of the canPublishProtected flag. 404 if the CI token or project belongs to another organization.

PATCH /me/ci-tokens/{id}/project-access/{projectId}

Flip the canPublishProtected flag on an existing grant.

Request:

json
{ "canPublishProtected": true }

404 if no grant exists for the (token, project) pair — POST first to create.

DELETE /me/ci-tokens/{id}/project-access/{projectId}

Revoke one project grant from a CI token. Returns 409 Conflict (with message: "delete the token instead") if this is the token's last grant — Worker enforces a min-1-grant invariant. Grant another project first, or hard-delete the token.

DELETE /me/ci-tokens/{id}

Permanently delete a CI token your organization owns. The token stops working immediately and cannot be brought back (there is no reactivate — match GitHub PATs / Stripe / WorkOS API keys: create → use → delete).

Request body (required, name-echo guard):

json
{ "confirmName": "github-actions" }

confirmName must equal the token's current name exactly — a missing or mismatched value returns 400 and deletes nothing. 404 if the CI token belongs to another organization.

GET /me/projects

List projects your organization owns, oldest first.

Response:

json
[
  {
    "id": "prj_…",
    "name": "Marketing site",
    "createdAt": 1234567890,
    "packageCount": 3
  }
]

Returns an empty array for a fresh organization — there is no auto-default project. Create the first project via POST /me/projects before adding packages or CI tokens.

POST /me/projects

Create a project. Counts against maxProjects (operator-set, default 20).

Request:

json
{ "name": "Marketing site" }

name is renamable via PATCH. Must be unique within the organization.

Response:

json
{ "projectId": "prj_…", "name": "Marketing site", "organizationId": "org_…", "message": "..." }
  • 403 Forbidden if you're at the project quota.
  • 409 Conflict if the name is already in use within your org.

GET /me/projects/{id}

Project detail, including its package list:

json
{
  "id": "prj_…",
  "organizationId": "org_…",
  "name": "Marketing site",
  "createdAt": 1234567890,
  "packages": [
    { "id": "pkg_…", "name": "checkout-widget", "created_at": 1234567890, "project_id": "prj_…", "owner_organization_id": "org_…" }
  ]
}

PATCH /me/projects/{id}

Rename a project.

Request:

json
{ "name": "Renamed project" }

DELETE /me/projects/{id}

Delete a project. Block-and-clear — Worker returns 409 (with the package count) if any packages still reference it; the admin must DELETE /me/packages/{id} each one first. Packages cannot move between projects; to keep the data, recreate the packages in another project before deleting the source. All projects are equal — there is no protected default project; an org may end up with zero projects after a delete (the next package or CI-token create then returns 422 with the empty-state guidance).

Request body (required, name-echo guard):

json
{ "confirmName": "Marketing site" }

GET /me/projects/{id}/audit

Project-scoped audit feed — rows where the affected package belongs to this project (resolved via the row's frozen project_id), plus project-lifecycle rows (project.create / project.rename / project.delete) and grant rows (project.access.* carrying this id in detail.projectId).

POST /me/members/{workosUserId}/project-access

Grant a member project-level access. Same shape as the CI-token equivalent above; the grant unlocks every package in the project. A member's effective access is the UNION of their per-package and per-project grants.

Request:

json
{ "projectId": "prj_…", "canPublishProtected": false }

PATCH /me/members/{workosUserId}/project-access/{projectId}

Flip canPublishProtected on an existing member grant.

DELETE /me/members/{workosUserId}/project-access/{projectId}

Revoke one member project grant. No min-1-grant invariant for members (per-package grants may still grant access).

POST /me/packages/{id}/channels

Create a new channel on a package your organization owns. Channels must exist before anything can be published to them — this is the only way an organization admin creates one.

Request:

json
{ "channel": "stage", "protected": false }

protected is optional and defaults to false. The channel name must satisfy the slug rule — matches /^[a-z0-9][a-z0-9-]{0,62}$/ (lowercase letters, digits, hyphen; 1–63 chars; must start with a letter or digit) — and the reserved names rollback and cleanup are rejected.

Response:

json
{ "packageId": "pkg_…", "channel": "stage", "protected": false, "message": "Channel 'stage' created" }
  • 201 Created on success. The channel starts empty — version_id is null until a version is published.
  • 400 Bad Request if the channel name fails the slug rule or is a reserved name.
  • 403 Forbidden if the package already has 10 channels (the per-package cap).
  • 404 Not Found if the package doesn't belong to your organization.
  • 409 Conflict if a channel with that name already exists on the package.

PATCH /me/packages/{id}/channels/{channel}

Mark a channel as protected or open, for a package your organization owns. Protected channels can only be published or rolled back by CI tokens whose grant on the package's project carries canPublishProtected = true (or member-role humans whose per-package OR per-project grant carries canPublishProtected). The operator and admin/owner-role humans bypass this.

Request:

json
{ "protected": true }

Returns 404 if the package doesn't belong to your organization.

Operating packages your organization owns — /v1/me/packages/{id}/...

An admin has full power over packages its organization owns — the CI-token operations (upload, publish, rollback, read) and package management (rename, delete channels, delete/clean up versions). No package grant and no canPublishProtected flag is needed — ownership is the authorization, so an admin may publish to protected channels too. Each endpoint mirrors a /v1/... counterpart exactly (same request/response — see the sections below); the only differences are the /me/ prefix and that the package must be owned by the calling organization (a package owned by another org, or by the operator, returns 404).

Method + pathMirrorsDoes
GET /me/packages/{id}GET /packages/{id}Package metadata
PATCH /me/packages/{id}PATCH /packages/{id}Rename the package ({ name }). Body { projectId } returns 400 — packages cannot move between projects
DELETE /me/packages/{id}operator endpointDelete the package — see the name-echo guard below
POST /me/packages/{id}/uploadPOST /packages/{id}/uploadUpload a new version
GET /me/packages/{id}/versionsGET /packages/{id}/versionsList versions (paginated)
GET /me/packages/{id}/versions/{versionId}GET /packages/{id}/versions/{versionId}Version URLs
GET /me/packages/{id}/versions/{versionId}/filesGET /packages/{id}/versions/{versionId}/filesList a version's files (name + size)
DELETE /me/packages/{id}/versions/{versionId}operator endpointDelete a version (protected ones → 409)
POST /me/packages/{id}/versions/cleanupoperator endpointBulk-delete old unprotected versions ({ "keep": N }, default 10)
GET /me/packages/{id}/channelsGET /packages/{id}/channelsList channels
GET /me/packages/{id}/channels/{channel}/historyGET /packages/{id}/channels/{channel}/historyThe channel's publish log (paginated)
GET /me/packages/{id}/auditGET /packages/{id}/auditThe package's audit log (paginated; ?limit= default 100)
POST /me/packages/{id}/channels/{channel}POST /packages/{id}/channels/{channel}Publish to a channel (open or protected)
POST /me/packages/{id}/channels/{channel}/rollbackPOST /packages/{id}/channels/{channel}/rollbackRoll a channel back one version
DELETE /me/packages/{id}/channels/{channel}operator endpointDelete a channel and its history (versions untouched)

DELETE /me/packages/{id} wipes everything — every version, channel, and stored file — so it is guarded: the request body must echo the package's current name.

json
{ "confirmName": "promo-banner" }

A missing or mismatched confirmName returns 400 and deletes nothing.


GET /packages

List packages you have access to.

Auth: CI token (returns scoped list).

Response:

json
[{ "id": "pkg_…", "name": "checkout-widget", "created_at": 1234567890 }]

GET /packages/{id}

Get package metadata.

Auth: CI token in the package's owning organization.

Response:

json
{ "id": "pkg_…", "name": "checkout-widget", "created_at": 1234567890 }

GET /packages/{id}/versions

List a package's versions, newest first. Paginated (see Pagination) — ?limit= + ?before=, returns { data, nextCursor }.

Auth: CI token in the package's owning organization.

Response:

json
{
  "data": [
    {
      "id": "ver_…",
      "package_id": "pkg_…",
      "r2_prefix": "packages/pkg_…/versions/ver_…",
      "file_count": 3,
      "total_size": 245678,
      "created_at": 1234567890,
      "uploaded_by": "alice@acme.com",
      "label": "v1.2.3",
      "commit_sha": "a1b2c3d"
    }
  ],
  "nextCursor": null
}

uploaded_by identifies whoever uploaded the version, captured at upload time — a CI token's name, a signed-in user's id, or the literal "operator" for operator uploads. It is null for versions uploaded before attribution was added.

label and commit_sha are optional source provenance, supplied at upload and frozen forever. label is a human version name (e.g. v1.2.3); commit_sha is the exact source commit. Both are null when the upload didn't supply them. They are opaque — Packdog stores and displays them but never interprets them.


GET /packages/{id}/versions/{versionId}

Get URLs for a specific version. Use this to test a version before publishing it to a channel.

Auth: CI token in the package's owning organization.

Response:

json
{
  "versionId": "ver_…",
  "packageId": "pkg_…",
  "createdAt": 1234567890,
  "uploadedBy": "alice@acme.com",
  "label": "v1.2.3",
  "commit": "a1b2c3d",
  "baseUri": "https://cdn.packdog.dev/packages/pkg_…/versions/ver_…"
}

label and commit are the version's frozen source provenance (null when not supplied at upload).


GET /packages/{id}/versions/{versionId}/files

List a version's files — the name + byte size of every file in the bundle, read directly from R2. Each name is relative to the version's baseUri (the path you append to load it — e.g. index.js, assets/app.css); the list is sorted by name.

Auth: CI token in the package's owning organization.

Response:

json
{
  "versionId": "ver_…",
  "packageId": "pkg_…",
  "files": [
    { "name": "assets/app.css", "size": 1840 },
    { "name": "index.js", "size": 5120 }
  ]
}

POST /packages/{id}/upload

Upload files for a new version. Multipart form-data — each field key is the relative file path, the value is the file. No required entry filename — a version is just a bundle of files served from baseUri; you load whatever entry your bundle has (index.js, main.js, index.html for an iframe, …). The only requirement is at least one file.

Auth: CI token in the package's owning organization.

Limits enforced server-side (per-organization caps; the values below are the defaults):

  • Max 10 MB per file
  • Max 20 MB total
  • Max 100 files per upload
  • File paths must be relative; segments equal to . or .., leading / or \, empty segments, control characters, and paths longer than 512 characters are rejected. Dot-prefixed paths like .well-known/security.txt are accepted by the API — but note the packdog CLI's deploy deliberately skips hidden files (they're usually secrets or VCS metadata, and uploads are served from a public CDN); upload one via the raw API if you genuinely need it.

An upload larger than the total cap is rejected with 413 Payload Too Large — early when Content-Length declares it, and in any case while the body is being read (the server never buffers past the cap, with or without a Content-Length header).

Optional source provenance. Two plain form fields, alongside the files, are frozen onto the version: label (a human version name, e.g. v1.2.3; ≤ 100 chars) and commit (the exact source commit; ≤ 100 chars, no whitespace). Both are opaque — stored and displayed, never parsed. Omit them and they stay null.

bash
curl -X POST https://api.packdog.dev/v1/packages/$PACKAGE_ID/upload \
  -H "Authorization: Bearer $TOKEN" \
  -F "index.js=@dist/index.js" \
  -F "style.css=@dist/style.css" \
  -F "label=v1.2.3" \
  -F "commit=$GITHUB_SHA"

Response:

json
{
  "versionId": "ver_…",
  "packageId": "pkg_…",
  "filesUploaded": 3,
  "totalSize": 245678,
  "label": "v1.2.3",
  "commit": "a1b2c3d",
  "message": "Version created. Use POST /packages/{id}/channels/{channel} to publish it."
}

Upload only creates the version — it does not affect any channel.


GET /packages/{id}/channels

List all active channels for a package.

Auth: CI token in the package's owning organization.

Response:

json
[
  { "package_id": "pkg_…", "channel": "prod", "version_id": "ver_…", "updated_at": 1234567890 },
  { "package_id": "pkg_…", "channel": "stage", "version_id": null, "updated_at": 1234567890 }
]

A channel that has been created but has nothing published to it yet appears with version_id: null.


GET /packages/{id}/channels/{channel}

Get the current version for a channel. This is what client code calls to load a package.

Auth: organization API key (load_...).

Response:

json
{
  "packageId": "pkg_…",
  "channel": "stable",
  "versionId": "ver_…",
  "publishedAt": 1234567890,
  "baseUri": "https://cdn.packdog.dev/packages/pkg_…/versions/ver_…"
}

baseUri is the root of the current version's files. Build your load URL by appending your bundle's entry — ${baseUri}/index.js for a <script>/ES-module, ${baseUri}/index.html for an <iframe src>, or whatever your build emits. Relative asset paths inside the bundle resolve against baseUri automatically. baseUri always points at the channel's current version, so it updates on publish/rollback without changing your load code.

Cached for 60 seconds at the edge (Cache-Control: public, max-age=60, s-maxage=60).

404 Not Found is returned if the channel doesn't exist, or if it exists but has no version published yet (Channel '...' has no published version yet).


PATCH /packages/{id}/channels/{channel}

Toggle one channel state at a time — branch protection ({ protected: bool }) or lock ({ frozen: bool }). Sending both, or neither, returns 400. A locked channel returns 409 to every publish + rollback (no caller bypass — even operator unlocks first, acts, then re-locks). The two flags are orthogonal: a channel can be protected, locked, both, or neither.

Auth: CI token with access to this package, or the org admin via the /me mirror.

Request (one of):

json
{ "protected": true }
{ "frozen": true }

Response (mirrors the flag that was set):

json
{ "packageId": "pkg_…", "channel": "prod", "protected": true, "message": "..." }
{ "packageId": "pkg_…", "channel": "prod", "frozen": true, "message": "..." }

POST /packages/{id}/channels/{channel}

Publish a version to an existing channel. The channel must already have been created — see POST /v1/me/packages/{id}/channels. This endpoint does not create channels.

Auth: CI token with a grant on the package's project. Publishing a protected channel additionally requires that grant's canPublishProtected = true.

Request: exactly one of these two source shapes. The first pins a specific version (deploy semantics); the second copies whatever the source channel currently points at (promote semantics) and records that source channel in the audit log so the activity view can show "Promoted from X".

json
{ "versionId": "ver_…" }            // deploy: specific version
{ "fromChannel": "test" }          // promote: copy current version of fromChannel

Sending both, or neither, returns 400. A fromChannel that doesn't exist returns 404; one that exists but is empty (no version published) returns 409.

Response:

json
{ "packageId": "pkg_…", "channel": "prod", "versionId": "ver_…", "previousVersionId": "ver_…", "message": "Published to channel 'prod'" }

previousVersionId is what the channel pointed at before this publish — null on a first publish. Clients render the transition (prod: ver_A → ver_B).

A promote echoes the source channel as well:

json
{ "packageId": "pkg_…", "channel": "prod", "versionId": "ver_…", "previousVersionId": "ver_…", "fromChannel": "test", "message": "Promoted to 'prod' from 'test'" }

404 Not Found is returned if the target channel doesn't exist (Channel 'prod' does not exist — create it first). Channel-name validation happens at channel creation, not here, so a typo'd channel name fails fast instead of silently creating a junk channel.


POST /packages/{id}/channels/{channel}/rollback

Roll back a channel to its previous version. Can be called multiple times — each call steps one version back through that channel's history.

Auth: CI token with a grant on the package's project. Rolling back a protected channel additionally requires that grant's canPublishProtected = true.

  • 400 Bad Request if the channel has no previous version to roll back to.
  • 409 Conflict if the channel was modified concurrently between read and write — retry.

Response:

json
{ "packageId": "pkg_…", "channel": "prod", "versionId": "ver_…", "previousVersionId": "ver_…", "message": "Channel 'prod' rolled back" }

versionId is what's live now (the rollback target); previousVersionId is what was live before the rollback.


GET /packages/{id}/channels/{channel}/history

The channel's publish log — every version ever published to the channel, newest first, with the currently-live entry flagged. Paginated (see Pagination) — ?limit= + ?before=, returns { data, nextCursor }.

Auth: CI token in the package's owning organization.

Response:

json
{
  "data": [
    { "historyId": "cha_…", "versionId": "ver_…", "label": "v1.2.3", "commit": "a1b2c3d", "publishedAt": 1234567890, "previousHistoryId": "cha_…", "current": true },
    { "historyId": "cha_…", "versionId": "ver_…", "label": null, "commit": null, "publishedAt": 1234560000, "previousHistoryId": null, "current": false }
  ],
  "nextCursor": null
}

current: true marks the version the channel points at right now — after a rollback that is an older entry, not the newest. Each entry carries the published version's frozen label + commit (both null when the version wasn't tagged). An empty data: [] means the channel exists but nothing has been published yet; 404 means the channel does not exist.

The log records version and timestamp only — not who published. For who-did-what, use the audit log below.


GET /packages/{id}/audit

The package's audit log — every mutating action on the package (upload, publish, rollback, the channel/version lifecycle, rename, grants), newest first, each with the actor that performed it.

Auth: CI token in the package's owning organization.

Paginated (see Pagination) — ?limit=N (default 100, capped at 1000) + ?before=<cursor>, returns { data, nextCursor }.

Response:

json
{
  "data": [
    {
      "id": "log_…",
      "timestamp": 1234567890,
      "actorType": "ci_token",
      "actorId": "cit_…",
      "actorName": "alice@acme.com",
      "actorOrganizationId": "org_…",
      "actorOrganizationName": "Acme Corp",
      "action": "channel.publish",
      "packageId": "pkg_…",
      "packageName": "checkout-widget",
      "packageExists": true,
      "projectId": "prj_…",
      "projectName": "Marketing site",
      "projectExists": true,
      "targetType": "channel",
      "targetId": "prod",
      "detail": { "versionId": "ver_…" }
    }
  ],
  "nextCursor": null
}

actorType is operator, ci_token, or user (a signed-in human). For an operator or ci_token actor, actorName is the display name captured at action time — it stays readable even after the acting token is deleted. For a user actor, actorId is the user id and actorName carries that id on the wire; the web panel resolves it to a real name when it renders the log. actorId is null for the operator. action is a stable dotted verb. packageName / projectName are frozen at write time — captured when the action happened, so they stay readable even after the resource is renamed or deleted; the companion packageExists / projectExists booleans say whether each resource still exists (so a client links only live ones). actorOrganizationId / actorOrganizationName are resolved at read from a CI token's row, null for operator and user actions or if the token has since been deleted. detail is an optional context object, or null.


GET /health

Public liveness signal — no other fields are exposed.

json
{ "status": "ok" }

GET /

Public API index. Returns a small JSON document linking to the docs. The marketing landing page is at packdog.dev, separate from the API.

json
{
  "name": "packdog-api",
  "version": "v1",
  "docs": "https://docs.packdog.dev",
  "health": "https://api.packdog.dev/health"
}

Client-side usage

The intended browser-side load:

javascript
const { baseUri } = await fetch(
  'https://api.packdog.dev/v1/packages/PACKAGE_ID/channels/stable',
  { headers: { 'Authorization': 'Bearer load_yourcustomerkey' } }
).then(r => r.json());

await import(`${baseUri}/index.js`);  // append your own entry — no required filename

const el = document.createElement('my-component');
el.baseUri = baseUri;  // package uses this internally to load its own assets
document.body.appendChild(el);

The organization load key (load_...) is a publishable key — it lives in browser JS by design and identifies the caller. It is read-only and scoped to packages your organization owns. If a key leaks, an organization admin rotates it via POST /v1/me/keys/rotate (or packdog keys rotate on the CLI): use mode: "immediate" to kill the leaked key at once, or the default grace mode for a routine zero-downtime roll where the old key keeps working briefly while you redeploy.


Typical CI-token workflow

bash
# Build and ship to the test channel in one step
packdog deploy --channel=test --dir=dist

# Test against the test channel, then promote that version to prod
packdog promote --to=prod --from=test

# If the prod build turns out broken
packdog rollback --channel=prod

See CLI reference and Examples for more.

Packdog runs on Cloudflare Workers, D1, and R2.