This page is the permalink for the code examples of Chapter 2 of Building Data Products, and the landing page for anything related to the Libot Services API. Bookmark the short link: jgp.ai/api.
The Libot Services are a small set of free REST services that turn ODCS data contracts and ODPS data products from a specification you read into artifacts you can build, version, compare, and score — with nothing more than curl. Point them at a DDL script and you get a contract back. Point them at a contract and you get a data product back.
Sections 1 to 9 are the whole tutorial, and they need nothing but an API key. The two appendices are separate on purpose, and neither is needed to follow along: Appendix A is OAuth 2.1 — how to get a token instead of pasting a key — and Appendix B maps every call in this article onto the modern /v4 surface, with a full walkthrough. Read them when your scripts start outliving your terminal session.
Everything below was run against the live service on August 14, 2026. Copy, paste, adapt.
1. At a glance
| Base URL | https://api.jgp.ai (canonical) or https://cloud.jgp.ai/api (as printed in the book) — see 2.1 |
| Auth (v1, used in sections 3 to 6) | X-API-KEY + X-USER-PASSWORD headers |
| Auth (v4, the modern surface) | Authorization: Bearer <JWT> — see Appendix A |
| Interactive docs | api.jgp.ai/swagger-ui.html |
| OpenAPI document | api.jgp.ai/api-docs |
| Health check | api.jgp.ai/v1/health |
| Standards emitted | ODCS v3.1.0 (contracts), ODPS v1.0.0 (products) |
| Source files | github.com/jgperrin/building-data-products |
2. Before you start
2.1 The base URL
Two forms work, and you can use whichever your copy of the material prints:
export BITOL_URL=https://api.jgp.ai # canonical export BITOL_URL=https://cloud.jgp.ai/api # as printed in the book and the tutorials
Some history, in case you hit it: the /api prefix stopped working for about six months after a server migration in February 2026 quietly dropped the routing rule that strips it. Everything published before then — Chapter 2 of the book, the four tutorials — answered 404 Not found. That is fixed as of August 14, 2026: both forms reach the service, on api.jgp.ai, cloud.jgp.ai and api.actianlabs.com alike. New code should prefer https://api.jgp.ai, which is the canonical name.
2.2 What you need
- curl and a text editor. Optionally jq to pretty-print JSON — every
| jqbelow can simply be dropped. - A real email address. Registration sends a six-character validation code, and the account does nothing until it is validated.
- Roughly ten minutes.
The examples use a UNIX-like shell (tested on macOS). Nothing in them is macOS-specific.
3. Get an API key
All the values that get reused live in environment variables, so the curl calls stay copy-pasteable.
export BITOL_URL=https://api.jgp.ai export BITOL_USER_EMAIL='you@example.com' export BITOL_USER_PW='<your-password>'
Two things the service is strict about: the email address cannot contain a + sign, and it must be one you can actually read. And the usual reminder — keep passwords, keys, and other credentials out of source control. A mistake comes way too quickly.
Register:
curl -X POST "$BITOL_URL/v1/users" \
-H "Content-Type: application/json" \
-d '{
"email": "'"$BITOL_USER_EMAIL"'",
"password": "'"$BITOL_USER_PW"'",
"firstName": "Your first name",
"lastName": "Your last name",
"company": "Your company",
"dob": "1971-10-05",
"code": "I<3BDP",
"comment": "Something nice about this book is always appreciated."
}'
[/bash]
<p>Only <code>email</code> and <code>password</code> are required; <code>code</code> is the reader code for <em>Building Data Products</em>. Do leave the placeholders behind, though: the service validates those fields and answers <code>400 Invalid first name, don't use the example values!</code> if you post the sample text verbatim.</p>
<p>The reply carries your key:</p>
[js gutter="false" autolinks="false"]
{"email":"you@example.com","firstName":"Your first name",
"lastName":"Your last name","company":"Your company","dob":"1971-10-05",
"comment":"Something nice about this book is always appreciated.",
"createdAt":"2026-08-14T15:50:29.650725",
"updatedAt":"2026-08-14T15:50:29.650736",
"apiKey":"00000000-0000-0000-0000-000000000000"}
[/js]
<p>Reading the reply: it echoes back everything you sent, adds two timestamps, and hands you the one value that matters.</p>
<ul>
<li><code>apiKey</code> — <strong>your credential.</strong> A UUID, generated server-side, that does not expire. It goes in the <code>X-API-KEY</code> header on every later call. The value shown above is a placeholder: yours will differ.</li>
<li><code>createdAt</code> / <code>updatedAt</code> — server-side timestamps, informational.</li>
<li>The <code>email</code>, <code>firstName</code>, <code>lastName</code>, <code>company</code>, <code>dob</code> and <code>comment</code> fields are what you posted, stored as-is.</li>
<li><strong>No password</strong> comes back, ever. It is stored as a BCrypt hash and cannot be read out again — if you lose it, no one can recover it for you.</li>
<li><strong>No validation status</strong> in this reply: the account exists but is <em>not</em> usable yet. That is the next step.</li>
</ul>
<p>Export the key (with your own value — the one above is a placeholder):</p>
[bash gutter="false" autolinks="false"]
export BITOL_API_KEY=00000000-0000-0000-0000-000000000000
Now check your inbox for the six-character code and validate the account. Until you do, every authenticated call answers 403 Account not validated.
curl -X GET "$BITOL_URL/v1/validate?email=$BITOL_USER_EMAIL&validationCode=AB12CD"
The two values are yours: email identifies the account, validationCode is the six-character code from the email. The reply is plain text, not JSON:
User successfully validated.
The other answers you may get, all plain text too: 200 User already validated. if you run it twice, 400 Invalid validation code. on a typo, 400 This verification code has expired. Request a new one to continue. if you waited too long, and 404 User not found. on an unknown email.
No code in your inbox? Ask for another one:
curl -X POST "$BITOL_URL/v1/users/resend-verification?email=$BITOL_USER_EMAIL"
That answers 200 Verification email sent. and mails a fresh code. It is rate-limited — ask too often and you get 429 with a Retry-After header, so wait rather than retry in a loop.
4. Turn a DDL script into a data contract
The promise comes first, so let’s start with the contract. The example uses a customer-and-addresses schema; the DDL is in the book’s repository.
-- Table: Customer
CREATE TABLE Customer (
customer_id SERIAL PRIMARY KEY,
first_name VARCHAR(255),
last_name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
phone VARCHAR(20)
);
-- Table: AddressType
CREATE TABLE AddressType (
address_type_id SERIAL PRIMARY KEY,
address_type VARCHAR(20) NOT NULL
);
-- Table: Address
CREATE TABLE Address (
address_id SERIAL PRIMARY KEY,
customer_id INT NOT NULL,
street1 VARCHAR(255) NOT NULL,
street2 VARCHAR(255),
city VARCHAR(100) NOT NULL,
state VARCHAR(100),
postal_code VARCHAR(20),
country_cd CHAR(2) NOT NULL,
address_type_id INT,
CONSTRAINT fk_customer FOREIGN KEY (customer_id) REFERENCES Customer(customer_id) ON DELETE CASCADE,
CONSTRAINT fk_address_type FOREIGN KEY (address_type_id) REFERENCES AddressType(address_type_id)
);
Send it up:
cat resources/customer.sql | \
curl -X POST "$BITOL_URL/v1/contracts?sourceFormat=DDL&version=0.1.0&name=CustomerContract&domain=Customer&tenant=QuantumClimate" \
-H "X-API-KEY: $BITOL_API_KEY" \
-H "X-USER-PASSWORD: $BITOL_USER_PW" \
-F "file=@-"
Reading the call: sourceFormat=DDL describes what you are sending, version=0.1.0 is a semantic version, and name, domain, and tenant are the contract’s identity — the business domain and the tenant (brand, business unit, department…) it belongs to.
{"domain":"Customer","name":"CustomerContract",
"id":"34cae6d7-7648-38b2-8f66-8db79e1e2ce4","version":"v0.1.0",
"createdTs":"2026-08-14T12:57:58.123Z","tenant":"QuantumClimate","status":"draft"}
What came back:
id— the contract’s identity, and the value you will pass to every other contract call. It is derived from the content, not random (more on that below).version— the version you asked for, echoed with avprefix (v0.1.0). Note the asymmetry: you send0.1.0in the query string, you getv0.1.0back. Both forms are accepted as input.status—draft. Every new contract starts here; the status is yours to advance as the contract matures, and it is what a consumer looks at to decide whether to depend on it.name,domain,tenant— the identity you supplied, stored as-is.createdTs— when the server stored this version, in UTC.- No schema in this reply. This is a header, not the contract; the parsed schema comes back from the
GETin 4.2.
You should get exactly that id: contract ids are derived from the content, so the same DDL always lands on 34cae6d7-7648-38b2-8f66-8db79e1e2ce4. Yes, UUIDs are ugly and unreadable. They are also a very powerful tool — and you only ever need the last four characters to recognize one, so let’s call this contract 2ce4.
export BITOL_CONTRACT_ID=34cae6d7-7648-38b2-8f66-8db79e1e2ce4
4.1 No local file? Use a URL
If you would rather not download anything, hand the service a URL instead of a file. Same result, one fewer step:
curl -X POST "$BITOL_URL/v1/contracts?sourceFormat=DDL&version=0.1.0&name=CustomerContract&domain=Customer&tenant=QuantumClimate&ddlUrl=https://raw.githubusercontent.com/jgperrin/building-data-products/main/chapter-02/resources/customer.sql" \ -H "X-API-KEY: $BITOL_API_KEY" \ -H "X-USER-PASSWORD: $BITOL_USER_PW"
The reply is identical to the file-upload form — same fields, same content-derived id, because the service hashes the same DDL either way.
dbmlUrl works the same way for DBML, and sourceFormat=auto lets the service detect the format. GET /v1/contracts/supported-databases lists what it can read directly over JDBC (PostgreSQL, MySQL, Informix, H2 as of today) — that one needs no authentication, so it is a nice first call to try.
4.2 Read it back and enrich it
curl -X GET "$BITOL_URL/v1/contracts/$BITOL_CONTRACT_ID" \ -H "X-API-KEY: $BITOL_API_KEY" \ -H "X-USER-PASSWORD: $BITOL_USER_PW" \ --output $BITOL_CONTRACT_ID-0.1.0.odcs.yaml
Three things worth knowing about that call: --output is curl’s, not the API’s — without it you get a long listing in the terminal. Add ?version=0.1.0 to pin a specific version; omit it and you get the latest. And ?format= asks for a different rendering (the PDF and documentation formats are the subject of the fourth tutorial in section 8).
The first lines look like this:
apiVersion: "v3.1.0"
contractCreatedTs: "2026-08-14T12:57:58.123+00:00"
dataProduct: ""
description:
usage: "DDL upload for contract generation"
purpose: "Defines schema based on uploaded DDL"
limitations: "None"
domain: "Customer"
id: "34cae6d7-7648-38b2-8f66-8db79e1e2ce4"
kind: "DataContract"
name: "CustomerContract"
schema:
- logicalType: "object"
name: "Customer"
physicalName: "Customer"
physicalType: "table"
properties:
- logicalType: "number"
name: "customer_id"
physicalName: "customer_id"
physicalType: "SERIAL"
primaryKey: true
required: true
- logicalType: "string"
name: "first_name"
physicalName: "first_name"
physicalType: "VARCHAR (255)"
…
This is ODCS, so every field is standard rather than something this service invented:
apiVersion— the version of the standard the document follows (ODCS v3.1.0 here), not the version of your contract. Your version lives inversion.kind—DataContract. The sibling value in ODPS isDataProduct; it is how a tool knows what it is reading.id,domain,name— the same identity you saw in the creation reply.contractCreatedTs— creation timestamp of this version.description.usage/.purpose/.limitations— prefilled with generic text because the service inferred this contract from a DDL script and a DDL script says nothing about intent. These are the first fields you should rewrite.dataProduct— empty until a data product claims this contract as an output port (section 5).schema[]— one entry per table.logicalType: objectandphysicalType: tablesay “this is a table”;nameis the logical name andphysicalNamethe one in the database. They differ once you rename things for consumers.schema[].properties[]— one entry per column.logicalTypeis the portable type (number,string),physicalTypethe database’s own (SERIAL,VARCHAR (255)).primaryKeyandrequiredwere lifted from the DDL’sPRIMARY KEYandNOT NULL.
That is an embryo, not a finished contract: structure, no meaning. Open it in your favorite editor (mine is still vi; VS Code is a fine alternative), add descriptions, quality rules, an SLA, owners — then save it as 0.1.1 and send it back:
curl -X POST "$BITOL_URL/v1/contracts?version=0.1.1" \ -H "X-API-KEY: $BITOL_API_KEY" \ -H "X-USER-PASSWORD: $BITOL_USER_PW" \ -F "file=@./$BITOL_CONTRACT_ID-0.1.1.odcs.yaml"
Two things about that call: there is no sourceFormat, because the service detects ODCS by looking for kind: DataContract in the payload; and the reply is the same header JSON as the first creation, with your new version. The id stays the same — you added a version to an existing contract, you did not create a second one.
5. Turn the contract into a data product
Now that there is a contract, a data product is a single call away:
curl -X POST "$BITOL_URL/v1/products?contractId=$BITOL_CONTRACT_ID&contractVersion=0.1.1" \ -H "X-API-KEY: $BITOL_API_KEY" \ -H "X-USER-PASSWORD: $BITOL_USER_PW"
Only two parameters, and both are required: contractId says which contract this product will expose, and contractVersion pins the exact version — the point of a data product is to promise a specific shape, not “whatever that contract looks like today”. You may also pass name, domain, tenant and version; leave them out and you get the defaults below.
{"id":"3153baf0-0c77-351d-b10c-a98578e10806","version":"0.1.0",
"status":"draft","name":"Default Data Product",
"createdTs":"2026-08-14T12:58:17.503598653Z"}
id— the product’s identity, content-derived like the contract’s. The same contract at the same version always yields this same product id.version—0.1.0, the default, since you did not ask for one. This is the product’s version and it moves independently of the contract’s.name—Default Data Product, because you did not supply one. Worth fixing before anyone sees it.status—draft, same lifecycle as contracts.createdTs— UTC, and note it is nanosecond-precision here where the contract reply used milliseconds. Cosmetic, but do not build a string comparison on it.
export BITOL_PRODUCT_ID=3153baf0-0c77-351d-b10c-a98578e10806
And retrieve it:
curl -X GET "$BITOL_URL/v1/products/$BITOL_PRODUCT_ID" \ -H "X-API-KEY: $BITOL_API_KEY" \ -H "X-USER-PASSWORD: $BITOL_USER_PW" \ --output $BITOL_PRODUCT_ID-0.1.0.odps.yaml
apiVersion: "v1.0.0"
kind: "DataProduct"
id: "3153baf0-0c77-351d-b10c-a98578e10806"
name: "Default Data Product"
version: "v0.1.0"
status: "draft"
domain: ""
tenant: ""
description:
purpose: "Automatically generated data product output contract"
outputPorts:
- name: "Default Data Product Output Port"
version: "v0.1.0"
contractId: "34cae6d7-7648-38b2-8f66-8db79e1e2ce4"
team:
name: "Data Product Team"
members:
- dateIn: "2026-08-14"
role: "owner"
name: "Your first name Your last name"
username: "you@example.com"
productCreatedTs: "2026-08-14T12:58:17.503598653Z"
This one is ODPS rather than ODCS, and the interesting part is what the service filled in for you:
apiVersion— the ODPS standard version (see the note below),kind: DataProductits counterpart toDataContract.outputPorts[]— the link that makes this a data product. One port, carryingcontractIdand its ownversion: the product says “here is what I publish, and here is the contract that governs it”. This is the field that turns two separate documents into one promise.description.purpose— generic, because it was generated. Another first thing to rewrite.team.members[]— you, asrole: owner, with thedateInyou were added and yourusername(your email). Ownership is not optional metadata in a data product; a product nobody owns is a product nobody fixes.domain/tenant— empty, because they were not supplied at creation. Contracts carry theirs; this product did not inherit them.status,version,productCreatedTs— as in the JSON reply above.
A data product with a name, a version, a status, an output port bound to a specific contract version, and an owner — generated, not typed. Note the apiVersion: the generator still emits ODPS v1.0.0, not the v1.1.0 shown elsewhere in the book. It is on the list.
Edit, save as 0.1.1, resubmit:
curl -X POST "$BITOL_URL/v1/products?version=0.1.1" \ -H "X-API-KEY: $BITOL_API_KEY" \ -H "X-USER-PASSWORD: $BITOL_USER_PW" \ -F "file=@$BITOL_PRODUCT_ID-0.1.1.odps.yaml"
As with contracts, the reply is the short header JSON with the new version, and the id does not change.
6. The other calls worth knowing
6.1 List everything you own
curl -X GET "$BITOL_URL/v1/contracts" \ -H "X-API-KEY: $BITOL_API_KEY" \ -H "X-USER-PASSWORD: $BITOL_USER_PW" | jq
[
{
"id": "34cae6d7-7648-38b2-8f66-8db79e1e2ce4",
"version": "v0.1.0",
"name": "CustomerContract",
"domain": "Customer",
"tenant": "QuantumClimate",
"status": "draft"
},
{
"id": "34cae6d7-7648-38b2-8f66-8db79e1e2ce4",
"version": "v0.1.1",
"name": "CustomerContract",
"domain": "Customer",
"tenant": "QuantumClimate",
"status": "draft"
}
]
Note what the list is: one row per version, not per contract. Two entries here, same id, versions v0.1.0 and v0.1.1 — the history is preserved, nothing was overwritten when you uploaded the enriched version. Each row carries only the header fields (id, version, name, domain, tenant, status); fetch a specific one to get its schema.
Products work identically:
curl -X GET "$BITOL_URL/v1/products" \ -H "X-API-KEY: $BITOL_API_KEY" \ -H "X-USER-PASSWORD: $BITOL_USER_PW" | jq
6.2 Recover a lost API key
It happens — you forget to export it and close the terminal. As long as you know your email and password, the key comes back. There is no way to recover the email or the password.
curl -X GET "$BITOL_URL/v1/users/key?email=$BITOL_USER_EMAIL" \ -H "X-USER-PASSWORD: $BITOL_USER_PW"
The reply is a single-field JSON object:
{"apiKey":"00000000-0000-0000-0000-000000000000"}
Note there is no X-API-KEY header on that call — the password is the credential here, which is the whole point: it is how you get back a key you no longer have.
The password goes in the header, never in the query string — a query-string password is ignored, and it lands in server logs.
6.3 Compare two versions and get a semver suggestion
The most useful operation when maintaining contracts over time is knowing exactly what changed between two versions, and whether the change is breaking. The compare endpoint returns a structured diff with an impact level per difference, and suggests the next version number.
Four parameters, in two pairs: id1 + version1 is the old side, id2 + version2 the new one. They are two independent pairs on purpose — passing the same id twice compares two versions of one contract, as here, but two different ids compares two contracts against each other, which is how you check whether a new contract is drop-in compatible with an old one.
curl -X GET "$BITOL_URL/v1/contracts/compare?id1=$BITOL_CONTRACT_ID&version1=0.1.0&id2=$BITOL_CONTRACT_ID&version2=0.2.0" \ -H "X-API-KEY: $BITOL_API_KEY" \ -H "X-USER-PASSWORD: $BITOL_USER_PW" | jq
{
"diffs": [
{
"type": "ADD",
"section": "schema",
"field": "quality",
"path": "/schema[0]/properties[2]/quality",
"message": "Quality rule added",
"impactLevel": "MINOR"
}
],
"major": 0,
"minor": 1,
"patch": 0,
"id1": "34cae6d7-7648-38b2-8f66-8db79e1e2ce4",
"version1": "v0.1.0",
"id2": "34cae6d7-7648-38b2-8f66-8db79e1e2ce4",
"version2": "v0.2.0",
"suggestedVersion": "v0.2.0"
}
Reading that reply, because two of its fields are easy to misread:
diffs[]— one entry per difference.typeisADD,REMOVEorCHANGE;pathis a JSON-pointer-style location into the document (/schema[0]/properties[2]/qualitymeans the third column of the first table);sectionandfieldare the human-readable version of the same thing;messageis a sentence you can drop into a changelog.impactLevel—MAJOR,MINORorPATCH, per difference. This is the field that answers “will this break my consumers?”.major,minor,patch— these are counts, not a version number."minor": 1means “one MINOR-impact difference was found”, not “the minor version is 1”. A response withmajor: 2, minor: 5, patch: 0is telling you it found seven differences, two of them breaking.suggestedVersion—version1bumped once, at the highest impact level present: any MAJOR at all bumps major and zeroes the rest, otherwise any MINOR bumps minor and zeroes patch, otherwise a PATCH bumps patch. Ten minor changes still bump the minor by one, which is what you want.id1,version1,storage1and their2counterparts — an echo of what you asked to compare, so a stored response is self-describing.
Adding a quality rule is backwards-compatible, so MINOR. Removing or renaming a column would be MAJOR — your consumers’ pipelines break, and the version number should say so before they find out the hard way.
6.4 Score a contract’s maturity
All data contracts are created equal, but they do not stay that way. A contract with a name and three column names is valid ODCS and delivers a fraction of the value of one with descriptions, an SLA, and quality rules. The maturity endpoint scores yours from 1 to 5 and tells you what is missing.
curl -X GET "$BITOL_URL/v1/contracts/maturity-level?contractId=$BITOL_CONTRACT_ID&version=0.1.0" \ -H "X-API-KEY: $BITOL_API_KEY" \ -H "X-USER-PASSWORD: $BITOL_USER_PW" | jq
{
"contractId": "34cae6d7-7648-38b2-8f66-8db79e1e2ce4",
"maturityLevel": "1",
"maturityLevelLabel": "Structural",
"explanation": "## Maturity Assessment: Level 1 — Structural …",
"nextLevelAdvice": "## How to Reach Level 2 — Descriptive …"
}
The parameters are just the contract and version to score. The reply:
maturityLevel—1to5, as a string. The levels are cumulative: you cannot reach 3 while failing a level-2 criterion.maturityLevelLabel— the human name for that number (Structuralat level 1).explanation— Markdown, not plain text: which criteria you satisfied and why you landed on this level. Render it, or read it raw with| jq -r .explanation.nextLevelAdvice— also Markdown, and the useful half: the specific criteria standing between you and the next level. This is a to-do list, not a grade.contractId— an echo of what was scored.
The freshly generated contract scores level 1, Structural — exactly what you would expect from a schema with no semantics attached. Run it again after each enrichment pass to check you are adding meaning and not noise.
This call also exists one version up, for anyone who has already moved to Bearer tokens (Appendix A) — same path, same parameters, one header instead of two:
curl -X GET "$BITOL_URL/v4/contracts/maturity-level?contractId=$BITOL_CONTRACT_ID&version=0.1.0" \ -H "Authorization: Bearer $BITOL_TOKEN" | jq
7. Endpoint cheat sheet
| What | Call |
|---|---|
| Register | POST /v1/users |
| Validate the account | GET /v1/validate?email=&validationCode= |
| Resend the validation code | POST /v1/users/resend-verification?email= |
| Recover the API key | GET /v1/users/key?email= (password in the header) |
| Contract from DDL / DBML / URL / JDBC | POST /v1/contracts?sourceFormat=&version=&name=&domain=&tenant= |
| Upload a contract | POST /v1/contracts?version= |
| Get a contract | GET /v1/contracts/{id}?version= |
| List contracts | GET /v1/contracts |
| Compare two versions | GET /v1/contracts/compare?id1=&version1=&id2=&version2= |
| Maturity score | GET /v1/contracts/maturity-level?contractId=&version= |
| Supported databases | GET /v1/contracts/supported-databases (no auth) |
| Product from a contract | POST /v1/products?contractId=&contractVersion= |
| Upload a product | POST /v1/products?version= |
| Get a product | GET /v1/products/{id}?version= |
| List products | GET /v1/products |
| Publish to GitHub | POST /v1/contracts/publish-github, POST /v1/products/publish-github |
| Health | GET /v1/health (no auth) |
The full, always-current list lives in Swagger UI.
8. Going further: the four hands-on tutorials
These four walkthroughs go deeper than this page, each on one theme. They use the https://cloud.jgp.ai/api base URL, which works again as of August 14, 2026 (see 2.1) — so they run exactly as published, no substitution needed.
- Experimenting with Data Contracts — build, validate, and store a contract from scratch.
- Playing with Data Products — go from contract to data product, and version both.
- Controlling Schema Drift — detect and analyze schema changes before they break consumers, using the compare endpoint.
- Making Pretty Documentation from Data Contracts — turn a contract into a PDF people actually want to read.
9. The fine print
- The service is free. That might not last forever, and it is not a production data platform — treat what you upload as experimental.
- The API evolves. Paths and payloads in this article are verified against the live service as of August 14, 2026; Swagger UI is always right, this page is a snapshot.
- Contract ids are content-derived and creation is idempotent, so re-running the examples is safe.
- Never commit an API key, a client secret, or a password. Use environment variables, and rotate anything that leaks.
Questions, bugs, or something that does not behave as described here? Tell me — and if you are reading Building Data Products, this page is the copy-paste companion to Chapter 2. It will be kept current as the API moves.
Appendix A. OAuth 2.1 and the v4 API
Optional. Nothing in sections 1 to 9 needs any of this. Read on when your scripts start outliving your terminal session.
A.1 Why move at all
Sections 3 to 6 use the /v1 surface with two headers: an API key and your password. It is the simplest thing that works, it is what the book uses, and it is not going away for contracts, products, and users. But it is legacy: an API key never expires, carries no scope, and cannot be narrowed. Every /v1 response now says so out loud:
Warning: 299 - "Deprecated: Use Bearer JWT authentication via OAuth 2.1 (/oauth2/authorize)"
The modern surface is /v4, and it authenticates with a short-lived Bearer token issued by the OAuth 2.1 authorization server that runs at https://api.jgp.ai. Two flows matter, and which one you want depends on who is holding the credential: A.3 if that is you at a keyboard, A.4 if it is a pipeline. Once you have a token, Appendix B shows what to do with it.
A.2 The discovery document
Start here. It is public, and it is the source of truth for every path in this appendix — if one of them ever moves, this document is where you find out.
curl -s https://api.jgp.ai/.well-known/oauth-authorization-server | jq
{
"issuer": "https://api.jgp.ai",
"authorization_endpoint": "https://api.jgp.ai/oauth2/authorize",
"token_endpoint": "https://api.jgp.ai/oauth2/token",
"registration_endpoint": "https://api.jgp.ai/oauth2/register",
"device_authorization_endpoint": "https://api.jgp.ai/oauth2/device_authorization",
"grant_types_supported": ["authorization_code", "client_credentials",
"refresh_token", "urn:ietf:params:oauth:grant-type:device_code",
"urn:ietf:params:oauth:grant-type:token-exchange"],
"code_challenge_methods_supported": ["S256"]
}
issuer— the identity of the authorization server. It must match theissclaim inside the tokens you receive; a mismatch means you are talking to the wrong server.authorization_endpoint,token_endpoint,registration_endpoint,device_authorization_endpoint— where each step of A.3 and A.4 happens. Read them from here rather than hard-coding them: the paths can change, this document cannot.grant_types_supported— the flows on offer.authorization_codeis A.3,client_credentialsis A.4,refresh_tokenrenews without a browser, and the device-code grant covers clients that cannot open one.code_challenge_methods_supported—["S256"]only, which is how you know PKCE is mandatory andplainis refused.scopes_supported(trimmed above) — the valid values forscope. Asking for anything outside this list fails withinvalid_scope.
PKCE is mandatory. There is no password grant and no implicit grant.
A.3 Flow A — you, at a keyboard (authorization code + PKCE)
For a CLI or a script you run yourself. There are three steps: register a client once, send yourself through a browser to get a one-time code, then trade that code for a token.
Step 1 — register the client. Registration is anonymous (RFC 7591 dynamic client registration), and every client is public: no secret is ever issued.
curl -X POST https://api.jgp.ai/oauth2/register \
-H "Content-Type: application/json" \
-d '{
"client_name": "My contract CLI",
"redirect_uris": ["http://127.0.0.1:8765/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"scope": "email workbench:session"
}'
Every field, and why it has that value:
client_name— free text, yours to choose. It is what the consent screen shows you later, so make it recognizable.redirect_uris— where the browser sends the code back. Pick any port you like;8765is arbitrary. The path must be exactly/callback, and the host must be127.0.0.1,localhost, or[::1]— the RFC 8252 loopback exception means the port is ignored when matching, which is what lets a CLI grab whatever port is free. A loopback address never leaves your machine, so nothing is exposed.grant_types—authorization_codeto get the first token,refresh_tokento renew it without a second browser trip. Those two are the only values accepted here.response_types—code, the only accepted value. There is no implicit grant.token_endpoint_auth_method—none, because a public client has no secret to authenticate with. PKCE replaces the secret. Send anything else and it is coerced tononeanyway.scope— what you are asking to do.email workbench:sessionis the pair that the Workbench web app itself requests, and it is what you want for the/v4contract and product endpoints. The full list of valid scopes isscopes_supportedin the discovery document (A.2).
The reply echoes your metadata and adds the one value you need to keep:
{"client_id":"11111111-2222-3333-4444-555555555555",
"client_id_issued_at":1786712446,
"client_name":"My contract CLI",
"redirect_uris":["http://127.0.0.1:8765/callback"],
"token_endpoint_auth_method":"none",
"grant_types":["authorization_code","refresh_token"],
"response_types":["code"],
"scope":"email workbench:session"}
client_id— your own, generated at registration. The UUID above is a made-up placeholder: yours will be a different value, and you copy it out of your own response. Register once, keep it, reuse it forever — there is no need to call/oauth2/registeragain.client_id_issued_at— a Unix timestamp, informational.- No
client_secret. That absence is by design, not an omission: public clients do not get one. Aclient_idis not a credential, so it is fine in a script or a repository. (If you need a real credential for unattended use, that is Flow B in A.4.)
Step 2 — get a code through the browser. PKCE means you invent a random secret (the verifier), send only its SHA-256 hash (the challenge) to the authorization endpoint, and prove you knew the original when you redeem the code. That is what makes a secretless client safe.
export CLIENT_ID=11111111-2222-3333-4444-555555555555 # YOUR client_id from step 1 export VERIFIER=$(openssl rand -base64 60 | tr -d '\n=+/' | cut -c1-64) export CHALLENGE=$(printf %s "$VERIFIER" | openssl dgst -binary -sha256 \ | openssl base64 | tr '+/' '-_' | tr -d '=') # macOS uses open; Linux uses xdg-open; or just paste the URL in a browser open "https://api.jgp.ai/oauth2/authorize?response_type=code\ &client_id=$CLIENT_ID\ &redirect_uri=http://127.0.0.1:8765/callback\ &code_challenge=$CHALLENGE&code_challenge_method=S256\ &scope=email%20workbench:session\ &resource=https://api.jgp.ai&state=$RANDOM"
VERIFIER— 64 random characters you generate locally and never transmit at this step.tr -d '\n=+/'strips the characters that are not legal in a PKCE verifier.CHALLENGE— base64url of the verifier’s SHA-256. Thetr '+/' '-_'andtr -d '='convert standard base64 into base64url, which is what the spec requires.code_challenge_method=S256— mandatory.plainis not accepted.redirect_uri— must match what you registered, including the path. The port may differ.scope— same values as at registration, space-separated, so%20in a URL.resource— RFC 8707. It binds the token tohttps://api.jgp.aias its intended audience, so a token minted for this API cannot be replayed against another service behind the same authorization server.state— an opaque value echoed back to you unchanged; compare it on return to be sure the response belongs to your request.$RANDOMis fine for a hand-run script.
Sign in, approve the consent screen, and the browser is redirected to http://127.0.0.1:8765/callback?code=…&state=…. Unless you happen to be running a listener on that port, the page will fail to load — that is expected and harmless. The code is in the address bar. Copy it from there.
Step 3 — trade the code for a token. The code is single-use and expires in minutes, so do this promptly.
# the code is single-use, so capture the whole reply once, then read both tokens out of it RESPONSE=$(curl -s -X POST https://api.jgp.ai/oauth2/token \ -d "grant_type=authorization_code" \ -d "code=<paste the code from the address bar>" \ -d "redirect_uri=http://127.0.0.1:8765/callback" \ -d "client_id=$CLIENT_ID" \ -d "code_verifier=$VERIFIER") export BITOL_TOKEN=$(echo "$RESPONSE" | jq -r .access_token) export BITOL_REFRESH_TOKEN=$(echo "$RESPONSE" | jq -r .refresh_token)
code— the one-time value you just copied out of the address bar.redirect_uri— the same one again. The server checks it matches the value used at/authorize; it is a binding check, not a place it will send anything.code_verifier— the original random string from step 2. The server hashes it and compares against the challenge it stored. This is the step a thief of your code cannot fake.- No secret and no
Authorizationheader on this call — correct for a public client.
What comes back: an access_token (an ES256-signed JWT, good for one hour on this server), a refresh_token (good for 30 days), token_type: Bearer, expires_in, and the granted scope. $BITOL_TOKEN is the access token — the variable used in section 6.4 and throughout Appendix B. Note the two-step capture: redeeming the code a second time fails, so do not run the exchange twice to fish out the second value.
When it expires, renew without a browser:
export BITOL_TOKEN=$(curl -s -X POST https://api.jgp.ai/oauth2/token \ -d "grant_type=refresh_token" \ -d "refresh_token=$BITOL_REFRESH_TOKEN" \ -d "client_id=$CLIENT_ID" | jq -r .access_token)
Refresh tokens rotate: each refresh returns a new one and invalidates the old, so store whatever came back last. If your client cannot open a browser at all, /oauth2/device_authorization implements the device-code grant instead — you get a code and a URL to open on another device.
A.4 Flow B — a pipeline, unattended (client credentials)
For CI/CD, do not ship a personal key. Use an organization-owned service account: a client_id and client_secret pair that trades for a one-hour token.
Where the two values come from. An admin of the organization creates the account once, using their own Bearer token from A.3. The plaintext secret is returned exactly once, at creation — there is no way to read it back later, and a lost secret means a rotate:
curl -X POST "https://api.jgp.ai/v4/orgs/service-accounts?org=$ORG_SLUG" \
-H "Authorization: Bearer $BITOL_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "contract-publisher",
"description": "Publishes contracts from CI",
"scopes": ["contracts:read", "contracts:publish"]
}'
Three fields in the body: name is required and identifies the account in listings and audit logs; description is free text for whoever finds it in six months; scopes is the list of permissions, and you should ask for the narrowest set that does the job (contracts:read, contracts:write, contracts:publish, contracts:sign, and the products:*, tags:read, catalog:*, datasources:* and maturity:* equivalents — GET /v4/orgs/service-accounts/scopes returns the live, authoritative list). The ?org= query parameter says which organization owns it.
{
"id": "0d9f1c74-...",
"clientId": "sa-7f3c1e08-...",
"orgId": "b21e...",
"name": "contract-publisher",
"description": "Publishes contracts from CI",
"scopes": ["contracts:read", "contracts:publish"],
"createdAt": "2026-08-14T13:05:11.402Z",
"revoked": false,
"clientSecret": "SHOWN-ONCE-COPY-IT-NOW",
"secretWarning": "This is the only time the client_secret is shown. Record it now."
}
clientSecret— the one and only time you will see it. Only a BCrypt hash is stored; there is no endpoint that returns it later. Lose it and your only option is to rotate.clientId— the public half, prefixedsa-so the server can route it to the service-account registry ahead of the DCR clients from A.3.secretWarning— the server telling you the same thing in the payload, in case a script is reading this and a human is not.scopes— what was actually granted. Compare it against what you asked for.id— the database identity, and the value you put in the rotate and revoke URLs. Not the same thing asclientId.orgId,createdAt,revoked— ownership, timestamp, and the kill switch’s current state.
Store those two values as CI secrets — BITOL_CLIENT_ID and BITOL_CLIENT_SECRET — and never anywhere else:
# In GitHub Actions these come from repository secrets, not from your shell: # BITOL_CLIENT_ID = the clientId above # BITOL_CLIENT_SECRET = the clientSecret shown once at creation # ORG_SLUG = your organization slug # 1. Exchange the credentials for a one-hour token TOKEN=$(curl -sf -X POST https://api.jgp.ai/oauth2/token \ -u "$BITOL_CLIENT_ID:$BITOL_CLIENT_SECRET" \ -d "grant_type=client_credentials" \ -d "scope=contracts:read contracts:publish" | jq -r .access_token) # 2. Call the API with it curl -sf -X POST "https://api.jgp.ai/v4/contracts/publish-github?contractId=$CONTRACT_ID&org=$ORG_SLUG" \ -H "Authorization: Bearer $TOKEN"
Two details in that exchange. -u "$BITOL_CLIENT_ID:$BITOL_CLIENT_SECRET" is HTTP Basic authentication — unlike A.3, this client does authenticate, which is why it needs no browser and no PKCE. And scope must be requested explicitly on every call: the reply carries access_token, token_type: Bearer, expires_in (one hour) and the granted scope, but no refresh token — when it expires you simply run the grant again.
Three things to know. Scopes are explicit: a token request without scope is granted nothing, and a call outside the granted scope returns 403 naming what is missing. Rotating (POST /v4/orgs/service-accounts/{id}/rotate?org={slug}) invalidates the old secret immediately. And service accounts are deliberately additive — they can read, create, publish, and sign, but they can never delete a contract, a product, or a data source. Deletion stays human.
Appendix B. Mapping the calls in this article to v4
Also optional. This is the translation layer: the same operations as sections 3 to 6, on the modern surface, with a token from Appendix A instead of two headers.
B.1 The summary table
The path shapes are preserved; what changes is the two headers becoming one, and organization context arriving as ?org={slug}.
| v1 (sections 3 to 6) | v4 equivalent |
|---|---|
X-API-KEY + X-USER-PASSWORD |
Authorization: Bearer <JWT> |
POST /v1/users |
POST /v4/users/register then POST /v4/users/verify |
GET /v1/users/key |
— no equivalent; v4 has no API keys, only tokens |
POST /v1/contracts |
POST /v4/contracts |
GET /v1/contracts, GET /v1/contracts/{id} |
GET /v4/contracts, GET /v4/contracts/{id} |
GET /v1/contracts/compare |
GET /v4/contracts/compare |
GET /v1/contracts/maturity-level |
GET /v4/contracts/maturity-level |
POST /v1/products, GET /v1/products, GET /v1/products/{id} |
same paths under /v4 |
| — | every /v4 POST accepts an Idempotency-Key header |
Query parameters are unchanged: POST /v4/contracts takes the same sourceFormat, version, name, domain, tenant, ddlUrl and dbmlUrl as its v1 counterpart, and POST /v4/products the same contractId and contractVersion. Response bodies are the same shapes too, so every field explanation in sections 3 to 6 still applies.
B.2 A full walkthrough on v4
Sections 3 to 5, start to finish, without an API key. Two genuine differences from v1 are worth pointing out before you run it: account verification moved from a GET with query parameters to a POST with a JSON body, and there is no key to retrieve — the token from Appendix A is the credential.
Step 1 — register. Same body as POST /v1/users, different path, no authentication:
export BITOL_URL=https://api.jgp.ai
export BITOL_USER_EMAIL='you@example.com'
export BITOL_USER_PW='<your-password>'
curl -X POST "$BITOL_URL/v4/users/register" \
-H "Content-Type: application/json" \
-d '{
"email": "'"$BITOL_USER_EMAIL"'",
"password": "'"$BITOL_USER_PW"'",
"firstName": "Your first name",
"lastName": "Your last name",
"company": "Your company",
"code": "I<3BDP"
}'
[/bash]
<p><strong>Step 2 — verify.</strong> The six-character code from your inbox goes in a JSON body, as <code>code</code> (not <code>validationCode</code>):</p>
[bash gutter="false" autolinks="false"]
curl -X POST "$BITOL_URL/v4/users/verify" \
-H "Content-Type: application/json" \
-d '{"email": "'"$BITOL_USER_EMAIL"'", "code": "AB12CD"}'
Step 3 — get a token. Run Flow A from A.3, which leaves the access token in $BITOL_TOKEN. Everything below uses it, and nothing below sends X-API-KEY or X-USER-PASSWORD ever again.
Step 4 — contract from a DDL script. Identical parameters to section 4, one header instead of two. Using ddlUrl so it runs with nothing on disk:
curl -X POST "$BITOL_URL/v4/contracts?sourceFormat=DDL&version=0.1.0&name=CustomerContract&domain=Customer&tenant=QuantumClimate&ddlUrl=https://raw.githubusercontent.com/jgperrin/building-data-products/main/chapter-02/resources/customer.sql" \ -H "Authorization: Bearer $BITOL_TOKEN"
The reply is the header JSON from section 4 — same content-derived id, same status: draft:
{"domain":"Customer","name":"CustomerContract",
"id":"34cae6d7-7648-38b2-8f66-8db79e1e2ce4","version":"v0.1.0",
"createdTs":"2026-08-14T13:12:04.881Z","tenant":"QuantumClimate","status":"draft"}
export BITOL_CONTRACT_ID=34cae6d7-7648-38b2-8f66-8db79e1e2ce4
Step 5 — read it back, enrich it, put it back. The ODCS document is byte-for-byte what section 4.2 describes:
curl -X GET "$BITOL_URL/v4/contracts/$BITOL_CONTRACT_ID?version=0.1.0" \ -H "Authorization: Bearer $BITOL_TOKEN" \ --output $BITOL_CONTRACT_ID-0.1.0.odcs.yaml # ... edit it, save as 0.1.1 ... curl -X POST "$BITOL_URL/v4/contracts?version=0.1.1" \ -H "Authorization: Bearer $BITOL_TOKEN" \ -H "Idempotency-Key: my-contract-upload-0.1.1" \ -F "file=@./$BITOL_CONTRACT_ID-0.1.1.odcs.yaml"
Idempotency-Key is the one thing v4 adds that v1 has no answer for, and it is optional. Send any unique string and the server caches the response against it, so a retried POST after a timeout replays the original result instead of creating a second version. If your pipeline retries on failure, use it.
Step 6 — the data product.
curl -X POST "$BITOL_URL/v4/products?contractId=$BITOL_CONTRACT_ID&contractVersion=0.1.1&name=Customer%20360" \ -H "Authorization: Bearer $BITOL_TOKEN"
Note name=Customer%20360: since you are here anyway, pass a real name rather than accepting Default Data Product.
Step 7 — list, compare, score. The read side, all with the same header:
curl -X GET "$BITOL_URL/v4/contracts" -H "Authorization: Bearer $BITOL_TOKEN" | jq curl -X GET "$BITOL_URL/v4/products" -H "Authorization: Bearer $BITOL_TOKEN" | jq curl -X GET "$BITOL_URL/v4/contracts/compare?id1=$BITOL_CONTRACT_ID&version1=0.1.0&id2=$BITOL_CONTRACT_ID&version2=0.1.1" \ -H "Authorization: Bearer $BITOL_TOKEN" | jq curl -X GET "$BITOL_URL/v4/contracts/maturity-level?contractId=$BITOL_CONTRACT_ID&version=0.1.1" \ -H "Authorization: Bearer $BITOL_TOKEN" | jq
Working inside an organization? Add ?org={slug} (or orgId={uuid}, which wins if both are present) to any of these, and the artifact is owned by the organization rather than by you personally. That is the parameter a service account from A.4 always needs, since a machine identity has no personal storage of its own.
My advice, in one line: learn the service on /v1 with the two headers, because it is the shortest path from zero to a contract in your hands — then move anything that outlives your terminal session to /v4 and OAuth.
