Skip to content

Authentication

Madoo’s Public API supports two authentication modes, both ending in a JWT Bearer access token you send on every request:

  1. API key / client-credentials (this document, §1–§5) — the standard machine-to-machine flow. Your application holds a pair of secrets (a client_id and a client_secret), exchanges them for a short-lived access token, and sends that token on every request. Best for “Any API” / server-to-server integrations and manual setups.
  2. OAuth connector (§6) — OAuth 2.0 Authorization Code + PKCE for a pre-registered confidential app connector (e.g. a no-code gateway acting on a user’s behalf). The user authorizes once; the connector then runs unattended on a rotating refresh token. This is the zero-paste, preferred mode for native app connectors. The same capability scopes (§3.2) and the same /api/v1 surface apply to both modes.

This document first covers the API-key mode end-to-end (the two things you do once — create a key — and the one thing you do continuously — obtain tokens), then the OAuth connector mode in §6.

Prerequisite: decide which environment you are targeting and note its base URL (see README §5). Credentials are environment-specific — a key created in Testing will not authenticate against Production.


An API key is the credential pair your application uses. You create it once, from the Madoo dashboard, in the organization/workspace you want your integration to act in.

The dashboard UI is the recommended path. Under the hood the dashboard calls Madoo’s authenticated application API (POST /api/api-keys); that endpoint is part of the internal app surface, not the Public API v1, so you do not call it from your integration — you use the key it produces.

  1. Sign in to the Madoo dashboard for your environment as an organization admin/owner (for an org-level key) or a workspace admin (for a workspace-level key).
  2. Open the API keys section of your workspace (or organization) settings.
  3. Create a new key, giving it a descriptive name (e.g. acme-newsletter-integration).
  4. Madoo returns two values:
    • client_id — a public identifier for the key. Not secret.
    • client_secret — the secret half of the pair.

⚠️ The client_secret is shown only once, at creation time. Madoo stores only a hash of it and can never display it again. Copy it immediately into your secret manager. If you lose it, revoke the key and create a new one.

Workspace-level vs organization-level keys

Section titled “Workspace-level vs organization-level keys”

Every key is ultimately scoped to one workspace — that is the workspace your API calls will operate in.

Key type Created by Scope When to use
Workspace-level Workspace admin (or org admin) Bound to one specific workspace The default choice. Use when your integration works inside a single project/brand.
Organization-level Org admin/owner Resolves to the organization’s default workspace Use only if you specifically need an org-scoped credential; it still acts inside the default workspace.

For almost all integrations, create a workspace-level key for the workspace that holds the workflows you want to run.

A key can carry an IP allowlist (AllowedIPs). When set, the key only works from those addresses — both the token exchange and every authenticated request from a non-allowed IP are rejected with 401 IP_NOT_ALLOWED. Leave it empty (the default) to allow any IP.

  • Format: one or more entries separated by comma/space — each an exact IP or a CIDR range, IPv4 or IPv6 (e.g. 203.0.113.7, 198.51.100.0/24, 2001:db8::/32). A malformed value is rejected when the key is created.
  • Use this only when your integration calls from stable, known egress IPs. Gateway-based connectors usually have stable egress; a serverless/runtime caller may not — leave the allowlist empty there.

Treat the client_secret like a password:

  • Keep it in a secret manager / environment variable — never commit it to source control or ship it in client-side code (browser, mobile app). The Public API is a server-to-server API.
  • If a secret is ever exposed, revoke the key from the dashboard (revocation takes effect immediately) and issue a new one.
Terminal window
# Example: keep credentials in environment variables
export MADOO_CLIENT_ID="ck_live_…"
export MADOO_CLIENT_SECRET="cs_live_…"
export MADOO_BASE_URL="https://testing-api.madoo.ai"

2. Exchange credentials for an access token

Section titled “2. Exchange credentials for an access token”

Send a POST to the token endpoint with grant_type=client_credentials. The request body is application/x-www-form-urlencoded (a form, not JSON — this is what the OAuth2 standard requires).

POST {BASE_URL}/api/v1/auth/token

You can present your credentials in either of two standard ways. Pick one — do not send both.

Put client_id:client_secret in the Authorization header as Base64 (client_secret_basic in OAuth2 terms). Most HTTP clients do the Base64 encoding for you.

Terminal window
curl -s -X POST "$MADOO_BASE_URL/api/v1/auth/token" \
-u "$MADOO_CLIENT_ID:$MADOO_CLIENT_SECRET" \
-d "grant_type=client_credentials"

(curl -u user:pass builds the Authorization: Basic … header automatically.)

Put the credentials in the form body alongside the grant type (client_secret_post):

Terminal window
curl -s -X POST "$MADOO_BASE_URL/api/v1/auth/token" \
-d "grant_type=client_credentials" \
-d "client_id=$MADOO_CLIENT_ID" \
-d "client_secret=$MADOO_CLIENT_SECRET"

On success you get HTTP 200 with:

{
"access_token": "eyJhbGciOiJ…",
"token_type": "Bearer",
"expires_in": 3600
}
  • access_token — the JWT to send on every subsequent request.
  • token_type — always Bearer.
  • expires_in — lifetime in seconds (3600 = one hour).

The token already encodes your organization and workspace context, so you never pass those explicitly — every call made with this token automatically operates inside the key’s workspace.


Send it as a bearer token on every Public API request (everything except the token endpoint itself):

Terminal window
curl -s "$MADOO_BASE_URL/api/v1/workflows" \
-H "Authorization: Bearer $ACCESS_TOKEN"

Calling any endpoint without a valid token — or with one that has expired — returns HTTP 401 Unauthorized.

Workspace context is required. Public API endpoints are protected by a policy that demands a valid workspace context, which your token always carries. If you somehow present a token with no workspace context, protected endpoints return HTTP 403 Forbidden. With a normally-issued API key token this never happens — it is mentioned only so the 403 is not a mystery.


3.1 Verify the credential — GET /api/v1/me

Section titled “3.1 Verify the credential — GET /api/v1/me”

A cheap, read-only connection test: it confirms the token works and reports what it can do, with no side effects. Use it to validate a stored credential and to show “you’re connected” in a UI.

Terminal window
curl -s "$MADOO_BASE_URL/api/v1/me" -H "Authorization: Bearer $ACCESS_TOKEN"
{
"organization": { "name": "Acme", "role": "Owner" },
"workspace": { "name": "Marketing", "role": "Admin" },
"credential": {
"type": "client_credentials",
"scopes": ["workflows:execute", "executions:read"],
"legacy_unscoped": false
},
"permissions": ["ws:executions:create", "ws:executions:read"],
"api_access": { "enabled": true },
"user": null,
"limits": null
}

Notes:

  • No tenant/user ids are returned — organization and workspace are identified by name (your credential is already bound to exactly one workspace, so it is the key you store).
  • user is null for API-key credentials. An API key authenticates as a workspace system account, not a person; the user block is populated only for credentials that carry a real interactive user.
  • credential.legacy_unscoped is true for an older API key created before scopes existed: it is grandfathered into full capability (scopes will be empty). Newer keys list their scopes.
  • api_access.enabled reflects whether your plan includes API access.
  • limits is reserved and currently always null.

Beyond who you are (workspace + role), a key declares what it is allowed to do through a small set of capability scopes. You choose them when you create the key in the dashboard; the token carries them, and each Public API endpoint requires the matching scope. This is least-privilege on top of RBAC: a request must satisfy both the endpoint’s required scope and the workspace-role permission.

A credential missing the required scope is rejected with HTTP 403 and code missing_scope — a different failure from RBAC’s 403 FORBIDDEN (role permission) so you can tell the two apart.

Older keys keep working. A key created before scopes existed carries no scopes and is grandfathered: it is allowed through every scope gate unchanged (/me reports credential.legacy_unscoped: true). Scope enforcement only ever applies to keys that declared scopes. To adopt least-privilege, create a new key with exactly the scopes your integration needs.

Scope Grants
catalog:read Browse the catalog: node types, models, presets, effects, capabilities.
workflows:read List/read workflows, definitions, versions; validate and estimate.
workflows:write Create, update, publish, archive, revert, clone, delete workflows.
workflows:execute Start and cancel executions (single and batch); mint runtime embed tokens.
executions:read List/read executions and batches, results, outputs, and packaged zips.
assets:read List assets and get download links. (Satisfied by assets:write too.)
assets:write Upload assets, import from URL, delete. Also satisfies assets:read.
design-templates:read Read document templates and their drafts, previews and publish readiness (11-design-templates.md).
design-templates:write Author, publish, archive document templates. Also satisfies design-templates:read.
webhooks:read List webhook subscriptions and deliveries (11-webhooks.md).
webhooks:write Create, update, delete webhook subscriptions.
Endpoint family Required scope
GET /capabilities, /node-types, /models, /presets, /effects catalog:read
GET /workflows, /workflows/{id}, /{id}/definition, /{id}/versions; POST /{id}/validate, /{id}/estimate workflows:read
POST /workflows; PUT /{id}/definition; PATCH /{id}; DELETE /{id}; POST /{id}/publish, /archive, /revert, /clone workflows:write
POST /executions, /executions/{id}/cancel; POST /batch-executions, /{id}/start, /cancel, /retry-failed workflows:execute
GET /executions…, /batch-executions… (list/get/result/outputs/zip); POST …/zip (packaging) executions:read
GET /assets, /assets/download-url assets:read
POST /assets (upload), DELETE /assets assets:write
POST /embed/tokens (runtime), DELETE /embed/tokens/{jti} workflows:execute
POST /embed/editor/tokens workflows:write
GET /me, /storage/*, /plan, .well-known/*, POST /auth/token (no capability scope)

The same scope vocabulary backs both the REST API and the MCP server, and the consent screen of an OAuth connector. assets:write deliberately implies assets:read, so a key that can upload can also list/download without holding both scopes.


3.3 Two gates: RBAC permissions vs capability scopes

Section titled “3.3 Two gates: RBAC permissions vs capability scopes”

Every authenticated call passes through two independent authorization gates, and it succeeds only if both allow it. They answer different questions:

  • RBAC (role-based access control) — “does the role behind this credential allow the action?” Your API key acts inside a workspace with a role (Owner / Admin / Editor / Viewer); the role grants a set of permissions (e.g. manage workflows, create executions). This is about who the credential acts as.
  • Capability scope — “was this credential granted this capability?” When you create the key you pick its scopes (workflows:execute, assets:write, …; see §3.2). This is about what this specific key is allowed to do, independent of how powerful its role is.

Think of RBAC as your job title (a Director may sign cheques) and a scope as a limited power of attorney you hand to one integration (“this key may only run workflows, never edit them”) — even if the role behind it could do far more. The effective power of a credential is the intersection: RBAC ∩ scope.

Why both? RBAC alone can’t express “a key that authenticates as a workspace admin but may only run workflows” — the admin role can do everything. Scopes let you issue a least-privilege key: powerful by role, deliberately narrowed by scope. Conversely, scope alone isn’t enough — if the underlying role lacks the permission, the action is still denied.

Worked example. A workspace Admin (full RBAC) creates a key for a connector, scoped to {workflows:execute, executions:read}:

Request RBAC (role) Scope (this key) Result
POST /executions (run) ✓ Admin ✓ has workflows:execute 200/202
GET /executions/{id} (read) ✓ Admin ✓ has executions:read 200
POST /workflows (create) ✓ Admin ✗ no workflows:write 403 missing_scope

The role would allow creating a workflow; the scope is what stops it — exactly the least-privilege you asked for. The two failures are distinguishable: a role/permission gap returns 403 FORBIDDEN, a scope gap returns 403 missing_scope.

Grandfathering (legacy API keys only). API keys created before scopes existed declare no scopes. To avoid breaking them, the REST scope gate lets a scope-less API-key credential through and relies on RBAC alone (/me reports credential.legacy_unscoped: true). Only API keys that declared scopes are scope-enforced.

OAuth runtime connector tokens are the exception — they are scope-strict. A token obtained through the OAuth connector flow (authenticated by the REST OAuth resource scheme; it carries a grant_id) is not grandfathered: a connector always mints a scoped token, so a connector token that arrives with no scope is rejected 403 missing_scope (fail closed), never waved through. Lenient grandfathering is reserved for the pre-scope API keys it was designed to protect.

(The MCP surface applies its own stricter variant of the scope-less rule — see the scopes section of MCP server.)


The token lives for one hour (expires_in: 3600). There is no refresh token in the client-credentials flow — when a token nears expiry you simply request a new one with the same credentials.

A robust client caches the token and re-requests it shortly before it expires, rather than asking for a new token on every call (which would quickly hit the token endpoint’s strict rate limit — see below). A common pattern: cache the token and its expiry, and fetch a fresh one when fewer than ~30–60 seconds remain.

// Minimal token cache (TypeScript / fetch)
let cache: { token: string; expiresAt: number } | null = null;
async function getToken(baseUrl: string, clientId: string, clientSecret: string): Promise<string> {
const now = Date.now();
if (cache && cache.expiresAt > now + 30_000) return cache.token; // still valid for >30s
const basic = btoa(`${clientId}:${clientSecret}`);
const res = await fetch(`${baseUrl}/api/v1/auth/token`, {
method: "POST",
headers: {
"Authorization": `Basic ${basic}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({ grant_type: "client_credentials" }),
});
if (!res.ok) throw new Error(`Madoo auth failed: ${res.status} ${await res.text()}`);
const json = await res.json();
cache = { token: json.access_token, expiresAt: now + json.expires_in * 1000 };
return cache.token;
}

Token endpoint rate limit. The /api/v1/auth/token endpoint is rate-limited far more strictly than the rest of the API (in production, only a handful of requests per IP within a 15-minute window). This is deliberate — it protects against credential-guessing. Cache your token; do not mint a new one per request.


All errors use the standard Problem Details shape (see README §6). The token endpoint follows the OAuth2 error vocabulary in its code field:

HTTP code Meaning / fix
400 invalid_request The grant_type is missing, or the Basic header is malformed. Ensure grant_type=client_credentials is present.
400 unsupported_grant_type You sent a grant_type other than client_credentials. Only client credentials is supported.
401 invalid_client Credentials are missing, wrong, or the key is revoked/expired. Verify client_id/client_secret and that the key is active in this environment.
401 IP_NOT_ALLOWED The key has an IP allowlist (AllowedIPs) and your request IP is not in it. Returned both by the token endpoint and on authenticated requests. Add your egress IP/range to the key, or clear its allowlist.
403 missing_scope The key declares capability scopes but not the one this endpoint requires (see §3.2). Re-create the key with the needed scope. Distinct from RBAC’s 403 FORBIDDEN (missing workspace-role permission).

A successful integration check-list:

  • Key created in the same environment you are calling.
  • client_secret copied correctly (no trailing whitespace).
  • Request is POST, body is form-encoded, grant_type=client_credentials present.
  • You received an access_token and are sending it as Authorization: Bearer ….

6. OAuth connector mode (zero-paste, for native app connectors)

Section titled “6. OAuth connector mode (zero-paste, for native app connectors)”

The second authentication mode is OAuth 2.0 Authorization Code + PKCE, for a connector that acts on a user’s behalf rather than holding a workspace’s own API key. A user authorizes the connector once, in a browser; the connector then calls /api/v1 with rotating tokens and never asks the user to paste a Madoo secret — that is why it is the preferred mode for a native app connector / gateway.

This mode is not self-service: production connector clients are pre-registered by Madoo as confidential OAuth clients (a client_id + a client_secret, an exact redirect URI, and the REST runtime scopes). Madoo shares the onboarding procedure, and the reasons behind the refresh-token lifetime, with each connector partner.

A connector bootstraps from standard discovery documents — no hard-coded endpoints:

  • Protected-resource metadata for the REST API: GET /.well-known/oauth-protected-resource/api-v1 (RFC 9728) — it points at the authorization server and lists the supported scopes. A 401 from /api/v1 with no/expired OAuth token also advertises it via the WWW-Authenticate: Bearer resource_metadata="…" header.
  • Authorization-server metadata at the authorization server’s /.well-known/oauth-authorization-server — it carries the authorization_endpoint, token_endpoint, and revocation_endpoint.
Section titled “The flow (authorize → consent → token → refresh → revoke)”
  1. Authorize — redirect the user to the authorization server’s authorization_endpoint with response_type=code, your client_id, the exact redirect_uri, a PKCE code_challenge (code_challenge_method=S256), and the scopes you need (or none — see the default bundle below).
  2. Consent — the user signs in, picks the organization + workspace the connector will act in, and approves the requested capabilities. The audience is fixed server-side to the REST API (/api/v1) from the client’s profile — the connector does not need to send an RFC 8707 resource parameter (it may, but only the REST resource is accepted; asking for the MCP resource is rejected invalid_target). A Madoo token is never valid on both /api/v1 and /mcp.
  3. Token — exchange the code + PKCE code_verifier at the token_endpoint for an access_token (short-lived, REST-audience JWT) and a rotating refresh_token. Authenticate the client with client_secret_basic (preferred) or client_secret_post.
  4. Call — send Authorization: Bearer <access_token> to /api/v1, exactly like the API-key mode. The access token carries the compact platform claims (uid, org_id, ws_id, org_role, ws_role, grant_id, scope).
  5. Refresh — when the access token nears expiry, exchange the refresh_token at the token_endpoint for a new pair. Rotation + reuse detection: each refresh invalidates the previous refresh token; replaying a consumed one revokes the whole chain. The connector’s refresh token has a longer, per-client lifetime than an interactive session (see the onboarding doc for the ceiling + rationale).
  6. Revoke — the user can disconnect the app at any time (Madoo “connected apps”); the connector can also call the revocation_endpoint. Revocation is immediate — the very next /api/v1 call is rejected 401 grant_revoked, without waiting for the access token to expire.

OAuth connector tokens are scope-strict: a connector always mints a scoped token, and a token that arrives with no scope is rejected 403 missing_scope (unlike a legacy scope-less API key, which is grandfathered — see §3.3). If the connector requests no scopes, consent grants the runtime default bundle:

catalog:read, workflows:read, workflows:execute, executions:read, assets:read, assets:write — note no workflows:write (a runtime connector executes and reads; it does not author workflows). The full scope vocabulary and per-endpoint requirements are in §3.2.

Supported connector modes (capability summary)

Section titled “Supported connector modes (capability summary)”
Mode Mechanism When
Native connector (preferred) OAuth 2.0 Authorization Code + PKCE, confidential pre-registered client, rotating refresh token, REST-audience access token A gateway / app acting on a user’s behalf — zero-paste
API key (fallback) client_credentials via POST /api/v1/auth/token (§1–§5) “Any API” / server-to-server / manual secret-based setups

Not supported: implicit grant, password grant, long-lived bearer access tokens without refresh, open/self-service dynamic client registration for production gateways, and any single token valid on both /api/v1 and /mcp.

OpenAPI. Where the OAuth connector surface is enabled for an environment, the Public API OpenAPI document advertises both security schemes — the API-key Bearer and the OAuth2 authorization-code flow (with the runtime scopes) — so tooling and the interactive /docs reference show both modes.


Next: 02-quickstart.md — put the token to work and produce your first output end-to-end.