Skip to content

Call Quarzo from a Broker backend ​

Use this guide to authenticate a Broker backend and call Quarzo as a Broker Service Account. For the other authentication methods, see Authentication.

Prerequisites ​

  • Node.js 24 or later for the example.
  • A Broker Administrator who can sign in to broker-front in your target environment. To accept the invitation and sign in, see Sign in to Quarzo.
  • The API base URL, ZITADEL issuer URL, and Quarzo project ID of your target environment. See Environments.

1. Get a Service Account and a Service Key ​

Ask the Broker Administrator to complete these steps in broker-front:

  1. In the sidebar, open Admin → Service Accounts.
  2. Select Create a Service Account and enter a display name for the backend.
  3. For that account, select Manage keys.
  4. Select Generate a key and enter a label that identifies the backend that holds the key.
  5. Select Download before you close the key dialog.
  6. Give the file to the backend operator through a secure channel, for example a secrets manager. Do not send it by email or chat.

The download has the name <label>-<keyId>.json. Quarzo provides the private key only once and does not store it. The file contains these fields:

json
{
  "type": "serviceaccount",
  "keyId": "<key-id>",
  "key": "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
  "userId": "<service-account-user-id>"
}

Store the file in a secrets manager or a file that only the backend process can read. Never commit the file. Never log the file, its private key, JWT assertions, or access tokens. Keep the file out of browser code and public assets. If the file is lost before storage, ask the Broker Administrator to generate a new key. Ask the Broker Administrator to revoke the lost key.

2. Get an access token ​

Use the JWT Bearer Grant to exchange a signed assertion at {authority}/oauth/v2/token. Here, authority is the ZITADEL issuer URL of the target environment, from Environments.

The assertion uses these values:

FieldValue
Header algRS256
Header kidThe keyId from the downloaded file.
Claim issThe userId from the downloaded file.
Claim subThe same userId.
Claim audThe exact ZITADEL issuer URL, with no trailing slash.
Claim iatThe current Unix time in seconds.
Claim expA short expiration time. The example uses five minutes.

Match the issuer exactly

The assertion's aud must equal the OIDC issuer for your environment, with no trailing slash. A URL from the ZITADEL administration console can have a trailing slash. A mismatch causes 400 invalid_grant with an invalid assertion message. The message does not identify the trailing slash as the cause.

Include the audience scope

Request urn:zitadel:iam:org:project:id:{quarzoProjectId}:aud. Replace {quarzoProjectId} with the Quarzo project ID of your environment, from Environments. Without this scope, ZITADEL can issue a token with the wrong audience. Quarzo rejects that token with 401.

The token request uses form encoding:

http
POST {authority}/oauth/v2/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
&assertion=<signed-jwt-assertion>
&scope=urn:zitadel:iam:org:project:id:{quarzoProjectId}:aud

Save this example as call-quarzo.mjs. It uses only built-in Node.js modules. It obtains a token and calls the current-identity endpoint without displaying the token.

js
import { readFile } from "node:fs/promises";
import { sign } from "node:crypto";

const required = (name) => {
  const value = process.env[name];
  if (!value) throw new Error(`Set ${name} before running this example.`);
  return value;
};

const authority = required("QUARZO_AUTHORITY");
const apiBaseUrl = required("QUARZO_API_BASE_URL").replace(/\/$/, "");
const projectId = required("QUARZO_PROJECT_ID");
// Optional. Set it only for delegation, as section 5 shows.
const exchangeProjectId = process.env.QUARZO_EXCHANGE_PROJECT_ID;
const serviceKey = JSON.parse(
  await readFile(required("QUARZO_SERVICE_KEY_FILE"), "utf8"),
);

if (authority.endsWith("/")) {
  throw new Error(
    "Set QUARZO_AUTHORITY to the issuer without a trailing slash.",
  );
}

const encode = (value) =>
  Buffer.from(JSON.stringify(value)).toString("base64url");
const now = Math.floor(Date.now() / 1000);
const signingInput = [
  encode({ alg: "RS256", typ: "JWT", kid: serviceKey.keyId }),
  encode({
    iss: serviceKey.userId,
    sub: serviceKey.userId,
    aud: authority,
    iat: now,
    exp: now + 300,
  }),
].join(".");
const signature = sign("RSA-SHA256", Buffer.from(signingInput), serviceKey.key);
const assertion = `${signingInput}.${signature.toString("base64url")}`;

const tokenResponse = await fetch(`${authority}/oauth/v2/token`, {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({
    grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
    assertion,
    scope: [projectId, exchangeProjectId]
      .filter(Boolean)
      .map((id) => `urn:zitadel:iam:org:project:id:${id}:aud`)
      .join(" "),
  }),
});
if (!tokenResponse.ok) {
  throw new Error(`Token request failed: HTTP ${tokenResponse.status}`);
}
const token = await tokenResponse.json();
if (typeof token.access_token !== "string" || !token.access_token) {
  throw new Error("The token response has no access_token.");
}

const callQuarzo = async (path) => {
  const response = await fetch(`${apiBaseUrl}${path}`, {
    headers: { Authorization: `Bearer ${token.access_token}` },
  });
  console.log(`GET ${path}: HTTP ${response.status}`);
  if (!response.ok) throw new Error("The Quarzo request failed.");
  return response;
};

await callQuarzo("/v1/authentication/me");

Replace every placeholder before execution. Environments gives the first three values:

bash
export QUARZO_AUTHORITY='<zitadel-issuer-without-trailing-slash>'
export QUARZO_API_BASE_URL='<api-base-url>'
export QUARZO_PROJECT_ID='<quarzo-project-id>'
export QUARZO_SERVICE_KEY_FILE='/private/path/to/service-key.json'
node call-quarzo.mjs

Expected result: GET /v1/authentication/me: HTTP 200. An assertion proves the machine's identity to ZITADEL. Send the resulting access token to Quarzo.

3. Call a business endpoint ​

Send the access token in the Authorization header:

http
GET /v1/premium-scheduling/subscribers/{subscriberId}/payers
Authorization: Bearer <access_token>

In the example, call callQuarzo with the path of the endpoint. Use the API reference for other business endpoints.

Reuse an access token until its expiration, as indicated by expires_in in the token response. Before expiration, obtain a new token with a new signed assertion.

Quarzo returns 401 for an expired token. After a revocation, ZITADEL refuses a token request signed with the revoked key. A token that the backend got before the revocation stays valid until it expires, 15 minutes at most. If a request returns 401, check the environment, issuer, audience scope, key status, and token expiration. If the key is revoked or expired, obtain an active key from the Broker Administrator. If a request returns 403, check the permissions for that endpoint.

When you ask Quarzo for help, send the environment, the HTTP status, the error code and the time of the request. Never send the key file, a private key, a JWT assertion or an access token.

4. Rotate the Service Key ​

Each Service Key expires one year after its generation. Rotate the key before the displayed expiration date. More than one key can be active at the same time.

  1. Ask the Broker Administrator to generate and download a new key for the same Service Account.
  2. Deploy the new key to every backend instance that uses the old key.
  3. Obtain a new access token with the new key.
  4. Verify a Quarzo request with that token.
  5. Ask the Broker Administrator to revoke the old key on the Service Keys page.
  6. Remove the old private key from backend storage.

5. Call Quarzo for a person ​

Use delegation when a person performs an operation in your Broker system. The person is a Subscriber or a Broker Advisor of your Broker. A Broker Administrator cannot be the person of a delegated token. The Broker backend uses a machine token to get the person's ZITADEL user ID. It then exchanges that ID and the machine token for a delegated token.

Get an Exchange Client Key ​

Follow Create an Exchange Client Key to generate, download, and store the key. Use the key file from the same Broker and environment as the Service Account.

Get a delegated token ​

Get a machine token as described in Get an access token, with one more audience. The token exchange accepts the machine token only when its audience contains the project of your Exchange Client. Set QUARZO_EXCHANGE_PROJECT_ID to the project ID of your Exchange Client. Open Administration → Delegation and copy Project id from the Exchange Client section. The value is not secret. The example then requests two audience scopes for the machine token:

text
urn:zitadel:iam:org:project:id:{quarzoProjectId}:aud urn:zitadel:iam:org:project:id:{exchangeProjectId}:aud

Quarzo accepts this machine token, because its audience contains the Quarzo project.

Use the machine token to resolve the person's ZITADEL user ID:

http
GET {apiBaseUrl}/v1/authentication/subjects/{externalUserId}
Authorization: Bearer <machine_access_token>

The response includes kind and insurerIdpUserId. Use insurerIdpUserId as the subject in the token exchange. kind is subscriber for a Subscriber and brokerUser for a Broker Advisor. The externalUserId must identify a Subscriber or a Broker Advisor of your Broker in the target environment. For a Broker Advisor, the Broker Administrator sets the external user ID on the Users page.

Append this code to the end of call-quarzo.mjs:

js
const exchangeClient = JSON.parse(
  await readFile(required("QUARZO_EXCHANGE_CLIENT_FILE"), "utf8"),
);
const externalUserId = required("QUARZO_EXTERNAL_USER_ID");

const subjectResponse = await fetch(
  `${apiBaseUrl}/v1/authentication/subjects/${encodeURIComponent(externalUserId)}`,
  { headers: { Authorization: `Bearer ${token.access_token}` } },
);
if (!subjectResponse.ok) {
  throw new Error(`Subject request failed: HTTP ${subjectResponse.status}`);
}
const subject = await subjectResponse.json();
if (
  !["subscriber", "brokerUser"].includes(subject.kind) ||
  !subject.insurerIdpUserId
) {
  throw new Error(
    "The external user id did not resolve to a Subscriber or a Broker Advisor.",
  );
}

const exchangeNow = Math.floor(Date.now() / 1000);
const exchangeInput = [
  encode({ alg: "RS256", typ: "JWT", kid: exchangeClient.keyId }),
  encode({
    iss: exchangeClient.clientId,
    sub: exchangeClient.clientId,
    aud: authority,
    iat: exchangeNow,
    exp: exchangeNow + 300,
  }),
].join(".");
const exchangeSignature = sign(
  "RSA-SHA256",
  Buffer.from(exchangeInput),
  exchangeClient.key,
);
const clientAssertion =
  `${exchangeInput}.${exchangeSignature.toString("base64url")}`;

const exchangeResponse = await fetch(`${authority}/oauth/v2/token`, {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({
    grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
    subject_token: subject.insurerIdpUserId,
    subject_token_type: "urn:zitadel:params:oauth:token-type:user_id",
    actor_token: token.access_token,
    actor_token_type: "urn:ietf:params:oauth:token-type:access_token",
    scope: `urn:zitadel:iam:org:project:id:${projectId}:aud`,
    client_assertion_type:
      "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
    client_assertion: clientAssertion,
  }),
});
if (!exchangeResponse.ok) {
  throw new Error(`Token exchange failed: HTTP ${exchangeResponse.status}`);
}
const delegatedToken = await exchangeResponse.json();
if (
  typeof delegatedToken.access_token !== "string" ||
  !delegatedToken.access_token
) {
  throw new Error("The token exchange response has no access_token.");
}

const callQuarzoForThePerson = async (path) => {
  const response = await fetch(`${apiBaseUrl}${path}`, {
    headers: { Authorization: `Bearer ${delegatedToken.access_token}` },
  });
  console.log(`GET ${path} with delegated token: HTTP ${response.status}`);
  if (!response.ok) throw new Error("The delegated Quarzo request failed.");
  return response;
};

const me = await (await callQuarzoForThePerson("/v1/authentication/me")).json();
console.log(`kind: ${me.kind}, authenticationMode: ${me.authenticationMode}`);

// Optional: a business endpoint for a Subscriber of your Broker.
const delegatedSubscriberId = process.env.QUARZO_SUBSCRIBER_ID;
if (delegatedSubscriberId) {
  await callQuarzoForThePerson(
    `/v1/premium-scheduling/subscribers/${encodeURIComponent(delegatedSubscriberId)}/payers`,
  );
}

Keep the values of section 2, and add these values for the same environment. Use the Exchange Client Key file from the same Broker and environment as the Service Key:

bash
export QUARZO_EXCHANGE_PROJECT_ID='<exchange-client-project-id>'
export QUARZO_EXCHANGE_CLIENT_FILE='/private/path/to/exchange-client-key.json'
export QUARZO_EXTERNAL_USER_ID='<external-user-id-of-the-person>'
# Optional: the ID of a Subscriber of your Broker, for the business endpoint.
export QUARZO_SUBSCRIBER_ID='<subscriber-id>'
node call-quarzo.mjs

Expected result:

text
GET /v1/authentication/me with delegated token: HTTP 200
kind: brokerUser, authenticationMode: delegated

For a Subscriber, kind is subscriber. The response of /v1/authentication/me also contains an actor block: the Service Account that acts for the person. When QUARZO_SUBSCRIBER_ID is set, the example also displays the HTTP status of the payers of that Subscriber.

Use the external user ID for the subject lookup. Use the Subscriber ID for the business endpoint. Request the Quarzo project audience scope during token exchange.

For the difference between machine access and delegation, see Authentication.

Website