# Medallia Experience Cloud

> Send survey feedback from Medallia Experience Cloud to Dimension Labs with the universal API.

This brings in completed feedback records from Medallia Experience Cloud: each comment question and the customer's answer, with scores and record fields alongside. You run it on your side with the [universal API](/api/tracker), using Medallia's Query API.

## What you need

- Experience Programs set up in your Medallia instance. Medallia lists this as a requirement for Query API.
- A Medallia OAuth 2.0 client ID and client secret, with a role that has the Query API capability.
- The field IDs of the comment questions and scores you want, for example your NPS field.
- A Dimension Labs API key for a new **Universal API** source. See [Your API key](/api/tracker#your-api-key).

## 1. Get API access in Medallia

Medallia's public steps are in [Authentication](https://developer.medallia.com/medallia-apis/reference/authentication). The steps for creating the client ID are in Medallia's product documentation, which you reach by logging in to your Experience Cloud instance.

1. Ask your Medallia administrator for an OAuth 2.0 client ID and secret. The role assigned to the client decides what data it can read, and that role must have the **Query API** capability. Credentials are per instance: production, sandbox and developer instances each need their own.
2. Request a token from your reporting instance with the client credentials grant, sending the client ID and secret as HTTP Basic authentication:

```bash
curl -X POST -u "$MEDALLIA_CLIENT_ID:$MEDALLIA_CLIENT_SECRET" \
  -d "grant_type=client_credentials" \
  "https://YOUR_INSTANCE.medallia.com/oauth/YOUR_TENANT/token"
```

3. The response has `access_token`, `token_type` and `expires_in` (3600 in Medallia's example). Send it as `Authorization: Bearer ACCESS_TOKEN` to the Medallia API gateway. Medallia notes that tokens come from the reporting instance, while API calls go to the gateway.

## 2. Pull new feedback

Query API is GraphQL. Every request is a `POST` to `/data/v0/query` on your API gateway, with a `query` and `variables`.

```bash
curl -X POST -H "Authorization: Bearer $MEDALLIA_TOKEN" -H "Content-Type: application/json" \
  --data-binary '{"query":"query { fields(ids: [\"YOUR_COMMENT_FIELD_ID\"]) { nodes { id name } } }","variables":{}}' \
  "https://YOUR_API_GATEWAY/data/v0/query"
```

- Use `feedback`, which holds only completed records. Medallia says queries on it are optimized. It takes `first`, `after`, `filter` and `orderBy`.
- **Incremental:** order by `e_responsedate` ascending and filter on it with `gte`. Medallia's streaming example also orders on the survey ID (`a_surveyid`) so that records with the same date come back in a stable order.
- Each record's `id` maps to `a_surveyid`. Read any field with `fieldData(fieldId: "...") { values labels }`.
- **Pagination:** request `pageInfo { hasNextPage endCursor }`. While `hasNextPage` is true, pass `endCursor` as `after` in the next query.
- Find field IDs and their names with the `fields` query shown above.
- **Rate limits (Medallia's):** 70 requests per second, 975,000 requests per 24 hours, and a 3,000,000 cost-unit limit per query.
- Medallia's data is eventually consistent, usually within seconds to minutes, so a record can appear shortly after its response date.

## 3. Map the fields

Each comment question becomes an outgoing message (the field's name) followed by an incoming message (the customer's answer), under the record ID. Skip blank comments.

| Dimension Labs | Medallia | Notes |
|---|---|---|
| `text` | field `name` from `fields` (outgoing), the comment field's `values` (incoming) | Field IDs differ between instances. Use your own. |
| `userId` | `id` | Use the record ID unless you have an internal customer ID field you trust (not an email). |
| `incoming` | | `false` for the question, `true` for the answer. |
| `sessionId` | `id` (`a_surveyid`) | |
| `conversationId` | `id` | |
| `dimensionlabs_timestamp` | `e_responsedate` | Convert with `toMillis`, which reads a date without a time zone as UTC. Add 1,000 ms per message. |
| `platformJson` | score fields such as NPS, `e_unitid`, and any other fields you query | Unit and program fields are useful for joins. |

**Personal data.** Query only the fields you need: Medallia records often hold the customer's name, email and phone in other fields. Redact comments too. See [PII redaction](/data/pii-redaction).

## 4. Send it

```js
// medallia.mjs. Environment: MEDALLIA_GATEWAY (your API gateway host), MEDALLIA_TOKEN_URL
// (https://YOUR_INSTANCE.medallia.com/oauth/YOUR_TENANT/token), MEDALLIA_CLIENT_ID, MEDALLIA_CLIENT_SECRET, DL_API_KEY.


const HOUR = 60 * 60 * 1000;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const COMMENTS = ['YOUR_COMMENT_FIELD_ID'];          // your comment field IDs
const EXTRA = ['YOUR_NPS_FIELD_ID', 'e_unitid'];      // scores and metadata for platformJson
const ids = [...COMMENTS, ...EXTRA, 'e_responsedate'];
const select = ids.map((f, i) => `f${i}: fieldData(fieldId: "${f}") { values }`).join(' ');
const QUERY = `query feed($filter: Filter, $after: ID) { feedback(first: 100, after: $after, filter: $filter,
  orderBy: [{ fieldId: "e_responsedate", direction: ASC }, { fieldId: "a_surveyid", direction: ASC }]) {
  nodes { id ${select} } pageInfo { hasNextPage endCursor } } }`;
// Match the format your instance returns for e_responsedate: check one record first.
const medalliaDate = (ms) => new Date(ms).toISOString().slice(0, 19).replace('T', ' ');
let token;

async function newToken() {
  const res = await fetch(process.env.MEDALLIA_TOKEN_URL, { method: 'POST', body: new URLSearchParams({ grant_type: 'client_credentials' }),
    headers: { Authorization: `Basic ${btoa(`${process.env.MEDALLIA_CLIENT_ID}:${process.env.MEDALLIA_CLIENT_SECRET}`)}` } });
  if (!res.ok) throw new Error(`Medallia token request returned ${res.status}: ${await res.text()}`);
  token = (await res.json()).access_token;
}

// Gets a fresh token on 401 (tokens last about an hour), and retries 429 and 5xx with a growing pause.
async function gql(query, variables = {}) {
  for (let attempt = 0; ; attempt++) {
    if (!token) await newToken();
    const res = await fetch(`https://${process.env.MEDALLIA_GATEWAY}/data/v0/query`, { method: 'POST',
      headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ query, variables }) });
    if (res.ok) return (await res.json()).data;
    if (res.status === 401 && attempt === 0) { token = null; continue; }
    if ((res.status !== 429 && res.status < 500) || attempt === 5) throw new Error(`Medallia returned ${res.status}: ${await res.text()}`);
    await sleep((Number(res.headers.get('retry-after')) || 2 ** attempt) * 1000);
  }
}

// Sends every completed record with a response date at or after `since` (Unix ms). Returns the new `since`.
export async function syncMedallia({ since, historical = false }) {
  token = null;
  const names = Object.fromEntries((await gql(`query { fields(ids: ${JSON.stringify(COMMENTS)}) { nodes { id name } } }`)).fields.nodes.map((f) => [f.id, f.name]));
  const filter = { fieldIds: ['e_responsedate'], gte: medalliaDate(since) };
  let after = null, newest = since;
  do {
    const { feedback } = await gql(QUERY, { filter, after });
    for (const n of feedback.nodes) {
      const val = (i) => n[`f${i}`]?.values?.[0];
      const responded = toMillis(val(ids.length - 1));
      const base = { userId: n.id, sessionId: n.id, conversationId: n.id, platformJson: Object.fromEntries(EXTRA.map((f, i) => [f, val(COMMENTS.length + i)])) };
      const old = historical || responded < Date.now() - HOUR;
      let t = responded;
      for (const [i, f] of COMMENTS.entries()) {
        const answer = String(val(i) ?? '').trim();
        if (!answer) continue;
        await sendToDimensionLabs({ ...base, text: names[f] ?? f, incoming: false, dimensionlabs_timestamp: t }, { historical: old });
        await sendToDimensionLabs({ ...base, text: answer, incoming: true, dimensionlabs_timestamp: t + 1000 }, { historical: old });
        t += 2000;
      }
      newest = Math.max(newest, responded);
    }
    after = feedback.pageInfo.hasNextPage ? feedback.pageInfo.endCursor : null;
  } while (after);
  return newest;
}
```

Each run asks for records from the cursor's date on (`gte`), so the records at the cursor come back each time. That's fine: duplicates are removed automatically. Medallia's guide also points to a fuller streaming example in Java, `medallia/query-api-data-extract` on GitHub.

## 5. Run it

Save the code as `medallia.mjs` in a project set up as in [Set up your project](/api/tracker#set-up-your-project), and run it with the [scheduled sync](/api/scheduled-sync) runner: `import { syncMedallia as sync } from './medallia.mjs';`.

- **History first:** `node run.mjs 2024-01-01` sends every completed record since that date to `/trackhistorical`. Then start a [backfill](/dimensions/backfill) from the **Backfill** tab.
- **Then every 15 minutes:** the scheduled job sends new records to `/track`, and they are enriched automatically every day. Medallia's data is eventually consistent, so a record can show up a little after its response date; one that is more than an hour old when the job sees it goes to `/trackhistorical`, and needs a backfill.
- **Re-runs are safe:** duplicates are removed automatically.

Medallia also describes batch exports over SFTP, S3 or similar; you can [upload such a file](/data/upload-a-file) and check it first with the [file checker](/data/check-a-file), then run a backfill.

## Medallia reference

- [Authentication](https://developer.medallia.com/medallia-apis/reference/authentication)
- [Query API overview](https://developer.medallia.com/medallia-apis/reference/query-api-overview)
- [Extract Survey Responses And Analytics](https://developer.medallia.com/medallia-apis/docs/extract-survey-responses-for-offline-analysis-data-warehousing)

Checked against Medallia's public developer documentation on 2026-10-13.
