Medallia Experience Cloud is the fabric over which customer and employee experiences can be captured, organized, analyzed, and actioned. Raw data can be extracted from Experience Cloud for archiving in data warehouses, or aggregated analytics can be run on the data and results pulled out and used. Medallia Query API is the engine powering these use cases.

Query API Anatomy

Query API is based on GraphQL. We'll cover the basics needed to query MEC below, but recommend you familiarize yourself with GraphQL's principles to get the most out of this guide.

GraphQL Structure

A GraphQL query is a JSON document that consists of two parts: the query itself and a map of variables that are applied to the query. The following is the basic structure:

{
  "query": "...(query as a string here)...",
  "variables": {
    ...(key/value pairs here)...
  }
}

The query is flattened as a string, making it difficult to read in this raw form. As such, most GraphQL clients provide input text areas for each of the two parts seen here (query/variables) and format the query as a multiline input, as shown below:

query {
  me {
    roles {
      id
      name
    }
  }
}
{
  "variable1": "value",
  "variable2": "anotherValue"
}

This will get flattened into the following:

{
  "query": "query {\n  me {\n    roles {\n...(omitted)...",
  "variables": {
    "variable1": "value",
    "variable2": "anotherValue"
  }
}

We show the GraphQL queries being used in the prettified format to make it easier to comprehend what’s going on; please translate to your GraphQL client of choice as needed.

Authentication

Query API uses OAuth 2.0 for authentication. For all the examples below, we use ${TOKEN} to refer to the OAuth 2.0 access token granted. See Authentication to learn how to obtain your token.

The data access role associated with the OAuth 2.0 account must be granted the Query API capability.

Rate Limits

Query API is subject to the following rate limits:

  • 10 requests per second.
  • 150,000 requests per 24-hour window.
  • 3,000,000 cost-unit limit per query.

📘

Cost and limits

Medallia limits the number of API calls per company to prevent unintended bugs in API client code or malicious code from taking down production systems. If you need higher limits, contact your Medallia representative to make the request.

GraphQL Objects

The query schema exposes many endpoints with a vast set of options on each. Endpoints are typically either record-level or aggregate-level in nature. The below are some of the most popular:

  • invitations: a view that includes both open/pending and completed records, such as customer transactions, events, survey invitations, and survey responses.
  • feedback: a view that includes only completed records, such as customer transactions, events, survey invitations, and survey responses.
  • customers: details about customers, independent of any invitations or feedback
    aggregate: calculations that aggregate multiple survey responses together into a single metric.
  • fields: metadata about the survey questions and other fields in Experience Cloud.

We’ll explore these in further detail in the sections below.

Online Documentation

Query API exposes the GraphQL schema for use with tools that contain online documentation capabilities, such as Altair GraphQL Client. (Medallia has no affiliation with the Altair project.) As this documentation reflects the “as-built” conditions for your MEC instance, we strongly recommend using such a tool to help craft your queries.

Data Types

GraphQL is strongly-typed. For the most part, they are what you would expect: Boolean, Int, String, etc. ID values should be encoded and treated as strings, but are called out especially for their semantic nature.

Invitations and Feedback

The invitations and feedback objects are very similar in nature, the only difference being whether to filter for records that have feedback attached to the event/transaction.

The invitations object represents all data records, regardless of the e_status value. The feedback object represents data records where the e_status value is COMPLETED. The feedback object should be used preferentially, as queries on it are optimized.

The below example shows some of the most commonly-used options:

query sample(
    $filter: Filter,
    $numPerPage: Int!,
    $orderBy: [RecordOrder],
    $pageCursor: ID,
    $status: ID!
) {
  # Use "invitations" or "feedback"
  invitations(
      after: $pageCursor,
      filter: $filter,
      first: $numPerPage,
      orderBy: $orderBy
  ) {
    nodes {
      id
      fieldData(fieldId: $status) {
        values
      }
    }
    totalCount
    pageInfo {
      endCursor
      hasNextPage
    }
  }
}
{
  "filter": {
    "and": [
      {
        "fieldIds": [
          "e_creationdate"
        ],
        "gte": "2021-01-01"
      }
    ]
  },
  "numPerPage": 100,
  "orderBy": [
    {
      "fieldId":
          "e_responsedate",
      "direction": "ASC"
    }
  ],
  "pageCursor": null,
  "status": "e_status"
}
{
  "data": {
    "invitations": {
      "nodes": [
        {
          "id": "12259397",
          "fieldData": {
            "values": [
              "COMPLETED"
            ]
          }
        },
        {
          "id": "12384775",
          "fieldData": {
            "values": [
              "COMPLETION_PENDING"
            ]
          }
        },
        ...
      ],
      "totalCount": 289759,
      "pageInfo": {
        "endCursor": "100",
        "hasNextPage": true
      }
    }
  }
}

Filters

Filters are built using a standard boolean structure. At the outer level, an aggregate boolean operator — such as and, or, not, etc. — wraps the individual conditions and other sub-aggregate operators.

The following pseudo-EBNF structure describes the various options and how they relate to each other:

FILTER         := { <OPERATOR> }

OPERATOR       := always: <BOOLEAN>
                | and: [<INNER_FILTER>, ...]
                | not: <INNER_FILTER>
                | or: [<INNER_FILTER>, ...]
                | ...

INNER_FILTER   := <FIELD_FILTER>
                | ...

FIELD_FILTER   := { fieldIds: [<STRING>, ...], <FIELD_OPERATOR> }

FIELD_OPERATOR := gt: <STRING>
                | gte: <STRING>
                | lt: <STRING>
                | lte: <STRING>
                | in: [<STRING>, ...]
                | isEmpty: <BOOLEAN>
                | hasNumericValue: <BOOLEAN>
                | ...

Using this, we create a multitude of filter conditions that are complex and nested, such as this:

{
  "or": [
    {
      "and": [
        {
          "not": {
            "fieldIds": ["e_status"],
            "in": ["0"]
          }
        },
        {
          "fieldIds": ["e_creationdate"],
          "lt": "2021-01-01"
        }
      ]
    },
    {
      "fieldIds": ["e_responsedate"],
      "gte": "2021-01-01"
    }
  ]
}

Field Data

Field data is accessed through the nodes wrapper. It consists of an ID that maps to the a_surveyid value (the unique record identifier on the Experience Cloud side) and a set of field accessor functions that enable typed access to field data.

Generally, the fieldData accessor is what can be used for most operations:

nodes {
  fieldData(fieldId: "e_some_field_id") {
    labels
    values
  }
  fieldData(fieldId: "e_some_other_field_id") {
    labels
    values
  }
}

This will map the field’s values to a JSON key that matches the fieldId specified.

Sometimes, the fieldId will be passed in as a variable, but you will need to map it to a constant identifier. We can use an alternate version of the GraphQL syntax to add a custom label to the fieldData value:

nodes {
  myField: fieldData(fieldId: "e_some_field_id") {
    labels
    values
  }
  myOtherField: fieldData(fieldId: "e_some_other_field_id") {
    labels
    values
  }
}
...
"nodes": [
  {
    "myField": {
      "values": [
        "SURVEY_ENGINE_ONLY"
      ]
    },
    "myOtherField": {
      "values": [
        7
      ]
    }
  },
  ...
],
...

Pagination

Access to all of Experience Cloud data is controlled through consumable pages of data. The pageInfo wrapper exposes two attributes that are relevant:

pageInfo {
  hasNextPage
  endCursor
}

The hasNextPage boolean indicates whether another page of data exists in the query. The endCursor ID is the pointer reference to the next page of data.

When hasNextPage is true and endCursor is not empty, pass the endCursor value into the next query’s after parameter:

query example($endCursorFromPreviousPage: ID) {
  # Use "invitations" or "feedback"
  feedback(after: $endCursorFromPreviousPage) {
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

The after parameter is often paired with the first parameter, which controls the number of results shown per page.

Customers (CX Profiles)

Profiles data is exposed through the customers object. Profiles is an add-on to the base Medallia Experience Cloud subscription. Contact your Medallia representative for further details.

The below example shows some of the most commonly-used options:

query sample(
    $filter: ContactFilter,
    $numPerPage: Int!,
    $orderBy: ContactOrder,
    $pageCursor: ID
) {
  customers(
      after: $pageCursor,
      filter: $filter,
      first: $numPerPage,
      orderBy: $orderBy
  ) {
    nodes {
      id
      email
      firstname
      lastname
      phone
    }
    pageInfo {
      endCursor
      hasNextPage
    }
  }
}
{
  "filter": {
    "and": [
      {
        "attributeKey":
            "first_name",
        "in": ["Chris"]
      }
    ]
  },
  "numPerPage": 100,
  "orderBy": {
    "attributeKey":
        "phone",
    "direction": "ASC"
  },
  "pageCursor": null
}
{
  "data": {
    "customers": {
      "nodes": [
        {
          "id": "1234567890",
          "email": "[email protected]",
          "firstname": "Chris",
          "lastname": "Smith",
          "phone": "(555) 867-5309"
        },
        ...
      ],
      "pageInfo": {
        "endCursor": "100",
        "hasNextPage": true
      }
    }
  }
}

Pagination is performed similarly to what is described in Invitations and Feedback.

Customer Analytics

The nodes wrapper can perform aggregate analytics for each customer profile returned, for example, to obtain a customer’s lifetime NPS. Medallia exposes several of these aggregate functions, each of which has many options and capabilities. The below shows the aggregate function, a basic building block that allows you to calculate metric data for each customer:

nodes {
  id
  email
  aggregate(definition: {
    data: { source: FEEDBACK },
    metric: { average: { field: { id: "e_ltr" } } }
  })
}
...
"nodes": [
  {
    "id": "1234567890",
    "email": "[email protected]",
    "aggregate": 9
  },
  ...
],
...

As with the fieldData nodes from invitations/feedback, we can set an alias on the function to give it a unique and descriptive name:

nodes {
  id
  email
  avgLtr: aggregate(definition: {
    data: { source: FEEDBACK },
    metric: { average: { field: { id: "e_ltr" } } }
  })
}
...
"nodes": [
  {
    "id": "1234567890",
    "email": "[email protected]",
    "avgLtr": 9
  },
  ...
],
...

See the Query API for details on other aggregate operations that can be performed and customer data that can be extracted.

Aggregate Analytics

Medallia’s powerful aggregate analytics can be accessed via Query API’s aggregation functions:

  • aggregate: single-number aggregation (such as overall NPS)
  • aggregateRank: calculates the rank of one dimension/metric relative to all others
  • aggregateTable: organizes aggregation results in a two-dimensional tabular form

Next, we will explore the first two through examples to show how to use these building blocks.

Simple Aggregations

A simple aggregate operation can be used to get a sense of overall behavior and trends:

query {
  avgLtr: aggregate(definition: {
    data: { source: FEEDBACK },
    metric: { average: { field: { id: "e_ltr" } } }
  })
}
{
  "data": {
    "avgLtr": 8.395187118201767
  }
}

We can filter the dataset to different segments to perform comparisons:

query {
  overall: aggregate(definition: {
    data: { source: FEEDBACK },
    metric: {
      average: { field: { id: "e_ltr" } }
    }
  })

  segmentA: aggregate(definition: {
    data: {
      source: FEEDBACK,
      filter: { fieldIds: "e_responsedate", lt: "2021-01-01" }
    },
    metric: {
      average: { field: { id: "e_ltr" } }
    }
  })

  segmentB: aggregate(definition: {
    data: {
      source: FEEDBACK,
      filter: { fieldIds: "e_responsedate", gte: "2021-01-01" }
    },
    metric: {
      average: { field: { id: "e_ltr" } }
    }
  })
}
{
  "data": {
    "overall": 8.395187118201767,
    "segmentA": 9.05432838178133,
    "segmentB": 8.102866137591433
  }
}

Ranked Aggregations

An aggregateRank operation is useful where the account has a limited scope (such as a call center agent being granted access to only her specific unit) and wants to rank that relative to some larger dataset.

The following example is made using an account with access to a single unit, looking to rank that unit across all units available in a data view with ID “27”:

query {
  aggregateRank(definition: {
    fieldKey: "e_unitid",
    metric: { average: { field: { id: "e_ltr" } } },
    minimumSampleSize: 15,
    myData: { source: FEEDBACK },
    rankedData: { dataView: { id: "27" } }
  }) {
    rank
    totalElements
    metricValue
  }
}
{
  "data": {
    "aggregateRank": {
      "rank": 263,
      "totalElements": 831,
      "metricValue": 8.658536585365853
    }
  }
}

Ranked aggregations like this are useful when trying to contextualize where your limited data view fits relative to the larger context’s data view. If your account has full access to the data anyway, you may need to apply myData filters to achieve the same result.

Fields

The fields endpoint allows a remote system to interrogate details about fields, used to store data on a data record.

query {
  fields {
    nodes {
      id
      name
      description
      dataType
      multivalued
    }
    pageInfo {
      hasNextPage
      endCursor
    }
    totalCount
  }
}
{
  "data": {
    "fields": {
      "nodes": [
        {
          "id": "a_surveyid",
          "name": "Survey ID",
          "description": null,
          "dataType": "INT",
          "multivalued": false
        },
        ...
      ],
      "pageInfo": {
        "hasNextPage": true,
        "endCursor": "MzA="
      },
      "totalCount": 2290
    }
  }
}

The ids filter allows us to query a specific field:

query {
  fields(ids: ["e_unitid"]) {
    nodes {
      id
      name
      description
      dataType
      multivalued
    }
  }
}
{
  "data": {
    "fields": {
      "nodes": [
        {
          "id": "e_unitid",
          "name": "Unit",
          "description": null,
          "dataType": "UNIT",
          "multivalued": false
        }
      ]
    }
  }
}

Other filters, such as for dataTypes or field ID search (q), are also available; see the product documentation for details.