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.
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:
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.
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 |
PCF001–PCF011 (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'shashis its strongETag. SendIf-None-Matchon a read and receive304 Not Modifiedwhen nothing has changed. The payload also carries ahash_contentvalue for content-only comparisons — telling whether the content itself changed, as opposed to metadata around it.Cache-Control— responses carry shortmax-agedirectives, 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.
Related pages
- 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.