Authentication
Madoo’s Public API supports two authentication modes, both ending in a JWT Bearer access token you send on every request:
- API key / client-credentials (this document, §1–§5) — the standard machine-to-machine flow. Your
application holds a pair of secrets (a
client_idand aclient_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. - 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/v1surface 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.
1. Create an API key
Section titled “1. Create an API key”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.
- 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).
- Open the API keys section of your workspace (or organization) settings.
- Create a new key, giving it a descriptive name (e.g.
acme-newsletter-integration). - Madoo returns two values:
client_id— a public identifier for the key. Not secret.client_secret— the secret half of the pair.
⚠️ The
client_secretis 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.
Optional: restrict a key to specific IPs
Section titled “Optional: restrict a key to specific IPs”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.
Store the credentials safely
Section titled “Store the credentials safely”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.
# Example: keep credentials in environment variablesexport 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/tokenYou can present your credentials in either of two standard ways. Pick one — do not send both.
Method A — HTTP Basic (recommended)
Section titled “Method A — HTTP Basic (recommended)”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.
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.)
Method B — credentials in the form body
Section titled “Method B — credentials in the form body”Put the credentials in the form body alongside the grant type (client_secret_post):
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"The response
Section titled “The response”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— alwaysBearer.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.
3. Use the token
Section titled “3. Use the token”Send it as a bearer token on every Public API request (everything except the token endpoint itself):
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.
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).
userisnullfor API-key credentials. An API key authenticates as a workspace system account, not a person; theuserblock is populated only for credentials that carry a real interactive user.credential.legacy_unscopedistruefor an older API key created before scopes existed: it is grandfathered into full capability (scopeswill be empty). Newer keys list theirscopes.api_access.enabledreflects whether your plan includes API access.limitsis reserved and currently alwaysnull.
3.2 Capability scopes
Section titled “3.2 Capability scopes”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 (
/mereportscredential.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.
The scopes
Section titled “The scopes”| 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. |
Required scope per endpoint family
Section titled “Required scope per endpoint family”| 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:writedeliberately impliesassets: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 (
/mereportscredential.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 rejected403 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.)
4. Token lifetime and refresh
Section titled “4. Token lifetime and refresh”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/tokenendpoint 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.
5. Authentication errors
Section titled “5. Authentication errors”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_secretcopied correctly (no trailing whitespace). - Request is
POST, body is form-encoded,grant_type=client_credentialspresent. - You received an
access_tokenand are sending it asAuthorization: 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.
Discovery
Section titled “Discovery”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. A401from/api/v1with no/expired OAuth token also advertises it via theWWW-Authenticate: Bearer resource_metadata="…"header. - Authorization-server metadata at the authorization server’s
/.well-known/oauth-authorization-server— it carries theauthorization_endpoint,token_endpoint, andrevocation_endpoint.
The flow (authorize → consent → token → refresh → revoke)
Section titled “The flow (authorize → consent → token → refresh → revoke)”- Authorize — redirect the user to the authorization server’s
authorization_endpointwithresponse_type=code, yourclient_id, the exactredirect_uri, a PKCEcode_challenge(code_challenge_method=S256), and the scopes you need (or none — see the default bundle below). - 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 8707resourceparameter (it may, but only the REST resource is accepted; asking for the MCP resource is rejectedinvalid_target). A Madoo token is never valid on both/api/v1and/mcp. - Token — exchange the
code+ PKCEcode_verifierat thetoken_endpointfor anaccess_token(short-lived, REST-audience JWT) and a rotatingrefresh_token. Authenticate the client withclient_secret_basic(preferred) orclient_secret_post. - 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). - Refresh — when the access token nears expiry, exchange the
refresh_tokenat thetoken_endpointfor 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). - 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/v1call is rejected401 grant_revoked, without waiting for the access token to expire.
Scopes
Section titled “Scopes”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
Bearerand theOAuth2authorization-code flow (with the runtime scopes) — so tooling and the interactive/docsreference show both modes.
Next: 02-quickstart.md — put the token to work and produce your first output end-to-end.