Skip to content

The Public API

The Public API is the one door into the Packt Platform. Every application — whether Packt built it or a partner did — talks to the platform through the same HTTP interface at https://api.packtpub.services. There are no private back channels and no admin-only shortcuts: Packt's own products call exactly the endpoints documented here, so anything Packt can do, you can do.

Its job is translation, not gatekeeping. Behind the door sits a set of cooperating internal services; the Public API turns each incoming HTTP request into one or more calls onto those services and stitches the answers back into a single response. That means an operation which would be several round trips for an outside caller — read a Resource, resolve its rights, hydrate a search page — becomes one request that the platform resolves internally at network speed. It also means the internal services can be rewritten, split, or merged without you ever noticing: the HTTP contract stays put while the plumbing behind it changes.

At launch, the whole public surface is the Content Lake: reading and writing content, the resolved rights that travel with every read, and search. Product composition and distribution arrive later under the same /v0/ prefix, without breaking anything shipped before them. This page is the orientation — the base URL, how you authenticate, the conventions every endpoint shares, and the route groups on the launch surface. The machine-readable contract is the OpenAPI document at https://api.packtpub.services/v0/openapi.json, which generates the reference site and every official SDK.

How a request flows

A public request never touches an internal service directly. It arrives at the Cloudflare edge — which terminates TLS, screens for abuse, and serves the platform's edge cache — and is passed to the Public API, which fans out to the services that own the data and assembles the result. Binary content is the one exception: bytes never pass through the API, so callers fetch Blobs directly from object storage using a short-lived presigned URL (see Blob access).

flowchart LR
    Client["Your app"] -->|HTTPS + JWT| Edge["Cloudflare edge<br/>TLS · cache · WAF"]
    Edge --> API["Public API<br/>translate · fan out · stitch"]
    API --> CL["Content Lake"]
    API --> Later["Products · Distribution<br/>(later in v0)"]
    Client -.->|presigned GET/PUT| Blobs[("Blob storage")]

The Public API is public ingress, not a trust boundary. Passing through it proves nothing: it validates your token at the edge so obviously bad requests die early, then forwards the same token, unchanged to the internal service, which runs the identical checks again. Nothing is trusted just because it made it past the door.

Authentication

Every request carries a WorkOS-issued JSON Web Token (JWT) in the Authorization header. There are no API keys — not for people, not for third-party apps, not for service-to-service calls. Every caller, without exception, presents a JWT.

Authorization: Bearer eyJhbGciOiJSUzI1NiIs...

The Tenant you act within is the token's tenant_id claim — there is no tenant header. Your permissions are the scopes carried in the token's scopes claim. You never assemble any of this yourself; you obtain a token through one of three flows, all of which produce the same claim set:

Caller Flow
A person signing in WorkOS AuthKit (including enterprise SSO), authorization code
A third-party app acting for a user OAuth Applications with hosted consent, authorization code
A service or integration acting as itself Machine-to-machine (M2M) client credentials

Authorisation happens in three layers, and a request must clear all three: the scope on your token, the access grant on the Resource, and the contract rights on the content. Each failure comes back distinctly so you can tell them apart — see Errors. The full token contract and validation order live in Authentication; the Public API follows it exactly and adds nothing of its own.

Conventions

Every endpoint shares the same base, prefix, and defaults.

Property Value
Base URL https://api.packtpub.services
Path prefix /v0/
Default content type application/json (negotiable — see Content negotiation)

A representative read names exactly the fields it needs and nothing more:

GET /v0/content-lake/resources/res_ml_go_ch3?fields=id,title,rights HTTP/1.1
Host: api.packtpub.services
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
Accept: application/json

Field masks

Reads are lean by default. A bare GET returns a Resource's own fields plus its always-resolved rights — it does not stitch in source, provenance, work, lineage, or the entity overlay, because those are expensive to compute and can multiply the payload several times over (a 1 MB document can carry 3–5 MB of graph context). You opt into them by name with a fields query parameter — an AIP-157 field mask:

GET /v0/content-lake/resources/res_ml_go_ch3?fields=id,title,rights,source,provenance

Name a mask on every read. fields=* returns the whole Resource and is a deliberate, expensive choice for the rare caller that genuinely needs everything — not a default. Masks support . traversal (for example rights.usage_terms). The full semantics, defaults, and server cost are specified in the Content Lake HTTP API.

Pagination

List endpoints paginate with opaque cursor tokens. Pass a page_size and, from the second page onward, the page_token the previous response gave you; each response carries a next_page_token until the listing is exhausted. Tokens are opaque — you never construct or inspect them, and there is no offset pagination.

GET /v0/content-lake/activities?type=ADAPTATION&page_size=20&page_token=eyJvIjoxMjV9

Search paginates differently: it is session-based, keyed by a search_id (a srch_ search session), and excludes previously returned hits automatically as you page through.

Idempotency

Any mutation can be retried safely. Provide an Idempotency-Key header on any POST, PUT, or PATCH:

POST /v0/content-lake/resources HTTP/1.1
Host: api.packtpub.services
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
Idempotency-Key: 7c4a8d09-ca95-427b-a3b0-93f5e4a2f9d1

The Public API translates the key to the internal AIP-155 request_id, so idempotency is enforced where the write actually happens. GET and DELETE are idempotent by definition and need no key.

  • Replay — the first request's status and body are stored, success or failure, and returned for every retry of the same key. A retry whose parameters differ is rejected.
  • Key format — client-generated: a V4 UUID or any random string with enough entropy, up to 255 characters.
  • Window — keys are pruned after 24 hours; a reused key after pruning is treated as a fresh request.
  • Safe retries — results are stored only once execution begins. A request that fails validation, or races a concurrent request for the same key, stores nothing and may be retried immediately.

Errors

When something goes wrong you receive a stable, machine-readable error in the AIP-193 shape. Branch on reason, never on the message text — the message is for humans and may change; reason and domain are the contract.

{
  "error": {
    "code": "PERMISSION_DENIED",
    "reason": "SCOPE_MISSING",
    "domain": "auth.packt.com",
    "message": "The token does not carry content-lake.read.",
    "details": []
  }
}

code is the underlying status name; the HTTP status line carries the mapping. The full gRPC-to-HTTP table lives in the service spec; the statuses you meet most often are:

Situation HTTP status
Invalid argument or failed precondition 400 Bad Request
Missing, invalid, or expired token 401 Unauthorized
Wrong tenant, or a scope the token lacks 403 Forbidden
Entity does not exist 404 Not Found
Conflict (already exists, stale token) 409 Conflict
Over the rate limit 429 Too Many Requests
Backend unavailable 503 Service Unavailable

The reasons you will meet on the launch surface, by domain:

Domain Reason HTTP
auth.packt.com TOKEN_MISSING, TOKEN_INVALID, TOKEN_EXPIRED 401
auth.packt.com TENANT_MISMATCH, SCOPE_MISSING 403
contentlake.packt.com PCF001PCF011 (content-format validation) 400
contentlake.packt.com LIMIT_EXCEEDED, CHECKSUM_MISMATCH 400
contentlake.packt.com TOKEN_MISMATCH (stale update_token) 409

The auth reasons are specified in Authentication; the Content Lake reasons in the Content Lake HTTP API. Failures without a custom reason use the plain code mapping — a missing entity is 404, a rate-limited caller is 429.

Caching and ETags

Caching is a first-class concern at every layer — your client, the edge, and the API itself.

  • ETag — a Resource's hash is its strong ETag. Send If-None-Match on a read and receive 304 Not Modified when nothing has changed. The payload also carries a hash_content value for content-only comparisons — telling whether the content itself changed, as opposed to metadata around it.
  • Cache-Control — responses carry short max-age directives, never less than one second. Even that window collapses hundreds of concurrent requests for a hot Resource into a single backend call.
  • Invalidation — when a Resource or its entity overlay changes, the affected URLs are purged from the edge cache, so a longer cache window never serves a stale correction.
GET /v0/content-lake/resources/res_ml_go_ch3 HTTP/1.1
Host: api.packtpub.services
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
If-None-Match: "sha256:0f3a9c…"

Rate limits

Limits are enforced per token — per client_id claim — so one application's burst cannot starve another's. Over your limit, you receive 429 Too Many Requests with a Retry-After header naming how long to wait; the official SDKs honour it and retry for you. The concrete quotas are per-environment configuration published in the developer portal, not part of this contract — always honour 429 and Retry-After regardless of the numbers currently in force.

The launch surface

The v0 surface at launch is the Content Lake in full. Everything below is live; Products and Distribution are later additions under the same /v0/ prefix.

Roadmap — what arrives after launch

Product Management and Distribution surfaces, and a dedicated contracts route group (full contract reads, including commercial terms), are later additions under the same /v0/ prefix. Until then, the Content Lake routes below are the whole public surface, and contract terms appear only as the resolved rights inline on content reads and search hits.

Each route group is specified authoritatively elsewhere; this catalogue names the paths and links onward. Note the real paths are /v0/content-lake/… — there is no bare /v0/resources.

Route group Representative routes Reference
Resources GET/POST/PUT/PATCH /v0/content-lake/resources… Content Lake HTTP API
Snapshot versions POST /v0/content-lake/resources/{id}/versions Content Lake HTTP API
Entity overlay GET /v0/content-lake/resources/{id}/entities Enrichment
Works GET/POST /v0/content-lake/works… Works & rights
Staging & promotion POST …/resources (no work_id), then POST …/works/{id}/resources Ingestion
Sources GET /v0/content-lake/sources/{id} Resources & versions
Activities GET /v0/content-lake/activities… Provenance
ContentUnits GET /v0/content-lake/content-units… Resources & versions
Access grants GET/PUT/DELETE /v0/content-lake/resources/{id}/access… Access & labels
Search POST /v0/content-lake/search Search
Blobs GET/POST /v0/content-lake/blobs… Blob access

Reading a Resource

A read stitches the referenced entities back onto the Resource: the Work's rights resolved to a plain usage view, a Source summary, the generating Activity. You see one coherent object; the platform did the joins once, server-side. rights is always present — even a staged Resource with no Work returns usable: false rather than a missing field.

Resource read with resolved rights (JSON, abridged)

A GET with fields=id,title,rights,source returns the Resource's own fields plus the always-resolved rights and the stitched Source summary. Rights are the effective result — contract terms already intersected with platform policy and the validity window — so usable is the one-glance verdict and usage_terms carry the detail.

{
  "id": "res_ml_go_ch3",
  "work_id": "wk_ml_go",
  "version": "ver_0197ccc",
  "title": "Chapter 3 — Goroutines and Channels",
  "uri": "cl://packt/document/res_ml_go_ch3/ver_0197ccc",
  "type": "DOCUMENT",
  "language": "en",
  "rights": {
    "usable": true,
    "contracts": [
      { "id": "ctr_chandra_2026", "status": "ACTIVE", "version": 3 }
    ],
    "territorial_rights": [
      { "worldwide": true, "status": "PERMITTED" },
      { "territory_codes": ["GB"], "status": "PROHIBITED" }
    ],
    "manifestation_rights": [
      { "manifestation_type": "EBOOK", "status": "PERMITTED" },
      { "manifestation_type": "AUDIOBOOK", "status": "PROHIBITED" }
    ],
    "usage_terms": [
      { "usage_type": "ACCESS", "status": "PERMITTED", "method": "STREAM" },
      { "usage_type": "DISPLAY", "status": "PERMITTED" },
      { "usage_type": "DERIVE", "status": "PROHIBITED" },
      {
        "usage_type": "AI_RETRIEVAL",
        "status": "PERMITTED",
        "method": "MCP",
        "exception": "Authenticated, metered"
      },
      { "usage_type": "AI_TRAINING", "status": "PROHIBITED" }
    ],
    "handling_terms": [
      { "handling_type": "ATTRIBUTION", "status": "REQUIRED" }
    ],
    "resolved_at": "2026-06-25T11:00:00Z",
    "basis": [
      { "contract_id": "ctr_chandra_2026", "contract_version": 3 }
    ]
  },
  "source": {
    "id": "src_ml_go_manuscript",
    "name": "Hands-On Machine Learning with Go — author manuscript",
    "origin_type": "UPLOAD",
    "system": "sharepoint"
  },
  "hash": "sha256:0f3a9c…",
  "created": "2026-02-01T09:12:00Z",
  "updated": "2026-04-18T16:40:00Z"
}

Commercial terms — royalties, advances, payment terms — are not here. They live on the Contract, are served separately, and are gated behind the contracts.commercial.read scope. Naming a commercial field without the scope returns 403; omitting it simply returns the Resource without it.

Content negotiation

The Content Lake serves the Packt Content Format (PCF) as JSON, and nothing else. Rendered formats are produced at the edge by the Public API, chosen with the Accept header — so the lake stays a single canonical store while new output formats can be added at the edge without touching the content.

Accept (or ?format=) Response Content-Type Body
application/json (default) application/json PCF JSON
application/x-ndjson (or ?format=ndjson) application/x-ndjson PCF blocks, one JSON element per line
text/markdown text/markdown Markdown rendered from PCF at the edge

NDJSON streams blocks as they arrive — sub-100 ms time-to-first-byte for progressive renderers and text-to-speech pipelines. The stream carries blocks only, so pair it with a metadata-only read (?fields=id,title,rights) if you need both. Markdown is a convenience rendering: it drops the entity overlay and knowledge-graph context that carry much of the Content Lake's value, so the common pattern is to consume PCF with entities, adapt it to your product, and emit Markdown to your downstream client last.

Blob access

Binary content — images, audio, video, repository archives — never moves through the API. The Public API fronts the Content Lake's valet-key model: it authorises you, then hands back a short-lived presigned URL scoped to a single object. You fetch the bytes directly from object storage.

Endpoint Behaviour
GET /v0/content-lake/blobs/{id} 302 Found; Location is a presigned GET URL (15-minute TTL). ?version= pins a blob version.
POST /v0/content-lake/blobs/locations Batch: up to 100 blob references in, location objects out (URL, size, content type, checksum). Unauthorised or missing references are simply absent from the response.
POST /v0/content-lake/blobs/uploads Start an upload — a single presigned PUT below the multipart threshold, part URLs above it.
GET /v0/content-lake/blobs/uploads/{uploadId}/parts Fetch further (or expired) part URLs.
POST /v0/content-lake/blobs/uploads/{uploadId}/complete Verify the declared SHA-256 and commit the immutable blob version.
DELETE /v0/content-lake/blobs/uploads/{uploadId} Abort and release an in-progress upload.

Reads are batch-first: to fetch many blobs, send their references to POST /v0/content-lake/blobs/locations in one call rather than issuing a redirect per blob. Presigned URLs are bearer credentials — fetch them immediately, never persist or log them, and never parse the hostname.

GET /v0/content-lake/blobs/blb_ml_go_cover HTTP/1.1
Host: api.packtpub.services
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...

HTTP/1.1 302 Found
Location: https://blobs.packtpub.services/…?X-Amz-Signature=…

The machine-readable contract

The API is described by an OpenAPI document that is the single source of truth for the public contract. It is versioned with the API and published at https://api.packtpub.services/v0/openapi.json. It generates the reference site and the client SDKs, powers request validation, and imports directly into tools like Postman. The 0 in the prefix is deliberate: the API is in its testing period, and the contract may still change in breaking ways while its first consumers exercise it. Renaming the prefix to /v1/ is the act of committing to backwards compatibility — from then on, changes within a version are additive only (new endpoints, new optional parameters, new response fields; clients must tolerate unknown response fields), and breaking changes only ever appear under a new version prefix, with both versions served during a published migration window.

Client SDKs

Official SDKs are generated from the OpenAPI spec so they never drift from the API. Each provides typed request and response objects, built-in token handling, automatic retries with idempotency-key support, and pagination helpers.

Language Package
JavaScript / TypeScript @packt/api-client
Go go.packtpub.services/api
Python packt-api
PHP packt/api-client

In development

The client SDKs are still in development and not yet published. Until they land, integrate against the OpenAPI document above — it is the contract the SDKs are generated from. This page will list concrete versions once each SDK ships.

  • Authentication — the WorkOS token contract, the three caller flows, and the scope model in full.
  • Events — how to react to platform change instead of polling the API.
  • No-code & AI tools — pointing Zapier, n8n, and LLM agents at the platform.
  • Content Lake — the estate the launch surface reads from and writes to.
  • Explorer — Chapter I — the service map and the request path, visualised.