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 | jq below 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."
  }'
&#91;/bash&#93;

<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> &mdash; <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> &mdash; 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 &mdash; 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 &mdash; 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 a v prefix (v0.1.0). Note the asymmetry: you send 0.1.0 in the query string, you get v0.1.0 back. Both forms are accepted as input.
  • statusdraft. 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 GET in 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 in version.
  • kindDataContract. The sibling value in ODPS is DataProduct; 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: object and physicalType: table say “this is a table”; name is the logical name and physicalName the one in the database. They differ once you rename things for consumers.
  • schema[].properties[] — one entry per column. logicalType is the portable type (number, string), physicalType the database’s own (SERIAL, VARCHAR (255)). primaryKey and required were lifted from the DDL’s PRIMARY KEY and NOT 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.
  • version0.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.
  • nameDefault Data Product, because you did not supply one. Worth fixing before anyone sees it.
  • statusdraft, 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: DataProduct its counterpart to DataContract.
  • outputPorts[]the link that makes this a data product. One port, carrying contractId and its own version: 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, as role: owner, with the dateIn you were added and your username (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. type is ADD, REMOVE or CHANGE; path is a JSON-pointer-style location into the document (/schema[0]/properties[2]/quality means the third column of the first table); section and field are the human-readable version of the same thing; message is a sentence you can drop into a changelog.
  • impactLevelMAJOR, MINOR or PATCH, per difference. This is the field that answers “will this break my consumers?”.
  • major, minor, patchthese are counts, not a version number. "minor": 1 means “one MINOR-impact difference was found”, not “the minor version is 1”. A response with major: 2, minor: 5, patch: 0 is telling you it found seven differences, two of them breaking.
  • suggestedVersionversion1 bumped 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, storage1 and their 2 counterparts — 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:

  • maturityLevel1 to 5, as a string. The levels are cumulative: you cannot reach 3 while failing a level-2 criterion.
  • maturityLevelLabel — the human name for that number (Structural at level 1).
  • explanationMarkdown, 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.

  1. Experimenting with Data Contracts — build, validate, and store a contract from scratch.
  2. Playing with Data Products — go from contract to data product, and version both.
  3. Controlling Schema Drift — detect and analyze schema changes before they break consumers, using the compare endpoint.
  4. 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 the iss claim 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_code is A.3, client_credentials is A.4, refresh_token renews 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 and plain is refused.
  • scopes_supported (trimmed above) — the valid values for scope. Asking for anything outside this list fails with invalid_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; 8765 is arbitrary. The path must be exactly /callback, and the host must be 127.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_typesauthorization_code to get the first token, refresh_token to renew it without a second browser trip. Those two are the only values accepted here.
  • response_typescode, the only accepted value. There is no implicit grant.
  • token_endpoint_auth_methodnone, because a public client has no secret to authenticate with. PKCE replaces the secret. Send anything else and it is coerced to none anyway.
  • scope — what you are asking to do. email workbench:session is the pair that the Workbench web app itself requests, and it is what you want for the /v4 contract and product endpoints. The full list of valid scopes is scopes_supported in 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_idyour 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/register again.
  • 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. A client_id is 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. The tr '+/' '-_' and tr -d '=' convert standard base64 into base64url, which is what the spec requires.
  • code_challenge_method=S256 — mandatory. plain is 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 %20 in a URL.
  • resource — RFC 8707. It binds the token to https://api.jgp.ai as 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. $RANDOM is 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 Authorization header 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."
}
  • clientSecretthe 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, prefixed sa- 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 as clientId.
  • 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"
  }'
&#91;/bash&#93;

<p><strong>Step 2 &mdash; 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.

Leave a Reply

Your email address will not be published. Required fields are marked *