Need help with your APIs? I offer API discovery, governance & evangelism services. Explore services →
API Evangelist API Evangelist
Learnings
Guidance
Toolbox
Alignment
API Evangelist LLC

Gravitee Gets Closer Than Most to Programmatic API Onboarding

July 12th, 2026 ·
Gravitee Gets Closer Than Most to Programmatic API Onboarding

I keep coming back to the same wall. Every company on earth is telling me they are all in on AI, that agents are the future, that software will soon be writing software and calling APIs on its own. And then I go to onboard to their platform and I am asked to click a checkbox proving I am not a robot, paste a key out of a dashboard by hand, and read three pages of docs to figure out where that dashboard even lives. The whole point of an agent is that nobody is sitting there to click the checkbox. The contradiction is right there in the open, and most vendors have not noticed.

I have been working my way through the major API gateway and management providers, asking one boring question of each: can a developer, or an agent acting on their behalf, register an application and walk away with working credentials without a human ever opening a browser tab? That is what I mean by programmatic API onboarding. SoundCloud, of all companies, set the bar with a tiny script that opens a browser once for OAuth and then POSTs to an app-registration endpoint and prints your client_id and client_secret to the terminal. That is the ideal. Most vendors are nowhere near it.

Gravitee is one of the closer ones, and I will say so plainly because I do not get to say it often.

Here is the honest mapping. Gravitee is self-hosted, so there is no single public sign-up URL the way SoundCloud has one — you point the script at your own APIM deployment. That is a fair tradeoff, not a dodge. The credential model is bucket (b): you generate a Personal Access Token once in the APIM Console, under your user profile in the Tokens section, and from then on the Management API does everything else over plain REST with Authorization: Bearer. No browser, no scraping, no clipboard. You create an application with POST /management/organizations/DEFAULT/environments/DEFAULT/applications, you subscribe that application to an API plan with POST /management/v2/.../apis/{apiId}/subscriptions, and then you read the autogenerated key back from GET /management/v2/.../subscriptions/{id}/api-keys, where it lands at data[0].key. Three documented calls and you have a credential an agent can actually use. That is the whole workflow, and it exists, and it works.

What is great here is that Gravitee did not stop at “create an app.” The app is useless without a subscription to a plan, and Gravitee lets you do the subscription programmatically too. A lot of platforms give you a way to mint a key and then make you click through a console to attach it to anything. Gravitee closed that loop. The script below leans on it: give it --name and it creates or reuses the application; give it --api as well and it finds an API-key plan, subscribes, and prints the key.

What is missing is mostly papercuts, but they are real. Application creation uses the v1 management API while subscriptions and keys use v2, which is the kind of seam you only discover by reading carefully or by getting a 404. Plan listing shifts shape across APIM versions, so my script tries to auto-pick an API-key plan but lets you pass --plan when the guess is wrong. And because this is the admin Management API, the PAT it wants is powerful — it is not a narrowly scoped onboarding credential, it is your full operator token. For an agent, that is more blast radius than I would like.

The thing that makes Gravitee genuinely interesting, and the reason I will give them real credit, is the other half of the house. Gravitee Access Management does true Dynamic Client Registration — RFC 7591, the actual standard — at {AM_GATEWAY}/{domain}/oidc/register. You POST client metadata, you get back a client_id, a client_secret, and a registration_access_token to manage the client afterward. That is the SoundCloud ideal expressed as an open spec instead of a proprietary endpoint. Unless the operator turns on open registration, you do need a bearer token with the dcr_admin scope from a client_credentials grant first, which is a sensible guardrail. So the capability for fully self-serve, standards-based client registration is sitting right there in the product. It just lives in a different module than the API-key flow most people start with.

Here is the script I committed to the repo at /assets/scripts/agentic-onboarding/gravitee-api-auth.mjs. It is a single file, Node 18 and up, standard library only, no npm install. Point it at your APIM with GRAVITEE_BASE_URL and GRAVITEE_TOKEN, optionally set GRAVITEE_ENV, and it does the create-app, subscribe, read-key dance and prints the credential.

#!/usr/bin/env node
/**
 * gravitee-api-auth.mjs
 *
 * Provider: Gravitee.io API Management (APIM), self-hosted / BYO-deploy.
 * What it does: Creates (or reuses) an Application via the Gravitee Management API,
 *   subscribes it to an API plan, and prints the resulting API key — the Gravitee
 *   equivalent of SoundCloud's client_id/client_secret. With --api you get a full
 *   create-app -> subscribe -> read-key flow; without it, it just creates/reuses the app.
 *
 * Auth model: BUCKET (b). Gravitee's Management API authenticates with a Personal
 *   Access Token (PAT). You generate the PAT once in the APIM Console
 *   (your user profile -> Tokens -> "Generate a personal token") and paste it via
 *   an env var. No browser dance is needed because the Management API is a first-class
 *   admin API. (Gravitee Access Management *also* does true RFC 7591 Dynamic Client
 *   Registration at {AM_GATEWAY}/{domain}/oidc/register — a separate product; see the
 *   blog post that ships with this script.)
 *
 * Env vars (required):
 *   GRAVITEE_BASE_URL  Base URL of your Management API, e.g. https://apim.example.com
 *                      (the script appends /management/... itself)
 *   GRAVITEE_TOKEN     Personal Access Token (PAT). Sent as: Authorization: Bearer <token>
 * Env vars (optional):
 *   GRAVITEE_ENV       Environment id (default "DEFAULT")
 *   GRAVITEE_ORG       Organization id (default "DEFAULT")
 *
 * Node 18+ (uses global fetch). Node stdlib only — no npm install.
 *
 * Docs:
 *   https://documentation.gravitee.io/apim/how-to-guides/use-case-tutorials/create-applications-and-subscriptions-using-the-management-api
 *   https://documentation.gravitee.io/apim/getting-started/use-case-tutorials/create-and-publish-an-api-using-the-management-api
 *   https://docs.gravitee.io/am/current/am_userguide_dynamic_client_registration.html  (AM DCR)
 */
import { parseArgs } from "node:util";
import process from "node:process";

function env(name, fallback) {
  const v = process.env[name];
  return v === undefined || v === "" ? fallback : v;
}

const BASE_URL = env("GRAVITEE_BASE_URL");
const TOKEN = env("GRAVITEE_TOKEN");
const ENVIRONMENT = env("GRAVITEE_ENV", "DEFAULT");
const ORGANIZATION = env("GRAVITEE_ORG", "DEFAULT");

function usage() {
  return `Usage: gravitee-api-auth [options]

  Creates (or reuses) a Gravitee Application via the Management API and, when an
  API is given, subscribes to one of its plans and prints the API key.

Options:
  --name           Required. Application name.
  --description    Optional. Application description.
  --api            Optional. API id to subscribe to. If omitted, the app is just
                   created/reused and its id is printed.
  --plan           Optional. Plan id to subscribe to. If omitted with --api, the
                   first API-key plan found on the API is used.
  -h, --help

Environment:
  GRAVITEE_BASE_URL  Required. e.g. https://apim.example.com
  GRAVITEE_TOKEN     Required. Personal Access Token (Console -> profile -> Tokens).
  GRAVITEE_ENV       Optional. Environment id (default DEFAULT).
  GRAVITEE_ORG       Optional. Organization id (default DEFAULT).

Example:
  GRAVITEE_BASE_URL=https://apim.example.com GRAVITEE_TOKEN=xxxxx \\
    node gravitee-api-auth.mjs --name "My Agent" --api 4984c004-... 
`;
}

function fail(msg) {
  console.error(`Error: ${msg}`);
  process.exit(1);
}

// --- Management API helper -------------------------------------------------
// Auth is a Personal Access Token sent as a bearer token.
async function gv(path, { method = "GET", body } = {}) {
  const url = `${BASE_URL.replace(/\/+$/, "")}${path}`;
  const headers = {
    accept: "application/json",
    authorization: `Bearer ${TOKEN}`,
  };
  if (body !== undefined) headers["content-type"] = "application/json";
  const res = await fetch(url, {
    method,
    headers,
    ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
  });
  const text = await res.text();
  let json;
  try {
    json = text ? JSON.parse(text) : {};
  } catch {
    json = { raw: text };
  }
  return { res, json, text };
}

const mgmtV1 = (p) =>
  `/management/organizations/${ORGANIZATION}/environments/${ENVIRONMENT}${p}`;
const mgmtV2 = (p) =>
  `/management/v2/organizations/${ORGANIZATION}/environments/${ENVIRONMENT}${p}`;

// --- Application: create or reuse ------------------------------------------
async function findExistingApplication(name) {
  // mAPI v1 lists the caller's applications. Filter by exact name.
  const { res, json } = await gv(mgmtV1("/applications"));
  if (!res.ok) return null;
  const list = Array.isArray(json) ? json : json?.data || [];
  return list.find((a) => a?.name === name) || null;
}

async function createOrReuseApplication({ name, description }) {
  // App creation uses mAPI v1.
  const { res, json, text } = await gv(mgmtV1("/applications"), {
    method: "POST",
    body: { name, description: description ?? "" },
  });
  if (res.status === 201 || res.status === 200) {
    return { app: json, existing: false };
  }
  // Some deployments reject a duplicate name; fall back to lookup-and-reuse.
  if (res.status === 400 || res.status === 409) {
    const existing = await findExistingApplication(name);
    if (existing) return { app: existing, existing: true };
  }
  throw new Error(
    `Create application (POST ${mgmtV1("/applications")}) failed: ${res.status} ${text}`
  );
}

// --- Subscription + API key ------------------------------------------------
async function pickApiKeyPlan(apiId, planArg) {
  if (planArg) return planArg;
  // NOTE: verify — plan listing shape varies across APIM versions; this path is
  // the documented v2 plans listing. If your version differs, pass --plan explicitly.
  const { res, json } = await gv(mgmtV2(`/apis/${apiId}/plans`));
  if (!res.ok) {
    throw new Error(
      `List plans (GET ${mgmtV2(`/apis/${apiId}/plans`)}) failed: ${res.status}. ` +
        `Pass --plan <planId> explicitly.`
    );
  }
  const plans = Array.isArray(json) ? json : json?.data || [];
  const keyPlan =
    plans.find((p) => (p?.security?.type || p?.security) === "API_KEY") ||
    plans.find((p) => /api[_-]?key/i.test(p?.security?.type || p?.security || "")) ||
    plans[0];
  if (!keyPlan?.id) {
    throw new Error("No subscribable plan found on the API. Pass --plan <planId>.");
  }
  return keyPlan.id;
}

async function subscribeAndGetKey({ apiId, applicationId, planId }) {
  const subPath = mgmtV2(`/apis/${apiId}/subscriptions`);
  const { res, json, text } = await gv(subPath, {
    method: "POST",
    body: { applicationId, planId },
  });
  if (res.status !== 201 && res.status !== 200) {
    throw new Error(`Create subscription (POST ${subPath}) failed: ${res.status} ${text}`);
  }
  const subscriptionId = json?.id || json?.data?.id;
  if (!subscriptionId) throw new Error("Subscription created but no id returned.");

  // Retrieve the autogenerated API key. Key lives at data[0].key.
  const keyPath = mgmtV2(`/apis/${apiId}/subscriptions/${subscriptionId}/api-keys`);
  const { res: kRes, json: kJson, text: kText } = await gv(keyPath);
  if (!kRes.ok) {
    throw new Error(`Read API keys (GET ${keyPath}) failed: ${kRes.status} ${kText}`);
  }
  const keys = Array.isArray(kJson) ? kJson : kJson?.data || [];
  const apiKey = keys[0]?.key;
  if (!apiKey) throw new Error("Subscription has no API key yet (it may be pending approval).");
  return { subscriptionId, apiKey };
}

// --- Output ----------------------------------------------------------------
function printResult({ app, existing, apiId, planId, subscriptionId, apiKey }) {
  const out = {
    application_id: app?.id,
    application_name: app?.name,
    application_client_id: app?.settings?.app?.client_id || app?.settings?.oauth?.client_id,
  };
  if (apiId) {
    out.api_id = apiId;
    out.plan_id = planId;
    out.subscription_id = subscriptionId;
    out.api_key = apiKey;
  }
  const lines = [];
  // Gravitee's "client_id" lives only on OAuth/DCR app types; the api_key is the
  // SoundCloud-equivalent credential for an API-key plan.
  if (out.application_client_id) lines.push(`client_id=${out.application_client_id}`);
  if (out.api_key) lines.push(`api_key=${out.api_key}`);
  lines.push("", JSON.stringify(out, null, 2), "");
  if (existing) console.error("Reused an existing application with the same name.");
  process.stdout.write(lines.join("\n"));
}

// --- Main ------------------------------------------------------------------
async function main() {
  const { values } = parseArgs({
    options: {
      name: { type: "string" },
      description: { type: "string" },
      api: { type: "string" },
      plan: { type: "string" },
      help: { type: "boolean", short: "h" },
    },
    strict: true,
    allowPositionals: false,
  });

  if (values.help) {
    console.log(usage());
    process.exit(0);
  }
  if (!BASE_URL) fail("GRAVITEE_BASE_URL is required (e.g. https://apim.example.com).");
  if (!TOKEN) fail("GRAVITEE_TOKEN is required (Personal Access Token).");
  if (!values.name) fail("--name is required.");

  const { app, existing } = await createOrReuseApplication({
    name: values.name,
    description: values.description,
  });
  if (!app?.id) fail("Application created but no id was returned.");

  if (!values.api) {
    printResult({ app, existing });
    return;
  }

  const planId = await pickApiKeyPlan(values.api, values.plan);
  const { subscriptionId, apiKey } = await subscribeAndGetKey({
    apiId: values.api,
    applicationId: app.id,
    planId,
  });
  printResult({ app, existing, apiId: values.api, planId, subscriptionId, apiKey });
}

main().catch((e) => {
  console.error("Error:", e?.message || e);
  process.exit(1);
});

So where does that leave Gravitee against the moment? Closer than most, and frustratingly so, because the pieces are all here. If I could mint a scoped, short-lived onboarding token instead of pasting my full operator PAT, and if the API-key plan flow and the AM DCR flow felt like one product instead of two, this would be the reference implementation everyone else copies. Right now it is two very good halves that have not quite shaken hands. Gravitee should make the DCR path a first-class, scope-limited onboarding story across the whole platform, and give agents a credential that is small enough to hand out safely. They are most of the way there. I will take what I can get, and I will keep pointing at the gap until it closes.