Overview
Adpulse is backed by a GraphQL API that supports all the data and functionality available in app.
This means that you need to familiarise yourself with how GraphQL apis are used. Apart from the official documentation linked above, Github also hosts a good guide on how to use this query language that may be handy for someone coming from REST api background. You can find this and other useful resources here
Tooling
GraphQL has a wide range of tools to aid developers implement API clients or simply explore APIs, you can browse them here. Additionally most IDEs would have tools to browse GraphQL API schemas and generate code from them.
Some developer tools that Adpulse team can recommend are:
Should you have any questions, please email [email protected]
Getting Access
API Schema
The API schema can be found at https://eu.api.adpulse.app/graphql/schema
curl https://eu.api.adpulse.app/graphql/schema -o adpulse.graphqls
Access Token
To access the api (beyond its schema), you need to include an access token in the HTTP authorization header:
export ADPULSE_HOME=https://eu.api.adpulse.app
export ADPULSE_TOKEN='<my-token>'
curl -H "Authorization: Bearer ${ADPULSE_TOKEN}" -X POST ${ADPULSE_HOME}/graphql --data @my-query.json
Your account owner needs to generate a "Developer Token" to access the API by browsing to the "Developer" Tab in the Admin section of the Settings menu in app. Make sure you store this token in a secure place, as this token won't be visible anymore after generation.
Using the API
Running Your First Query
GraphQL APIs have two types of requests: queries and mutations. Queries are read only and are equivalent to GET requests in REST APIs whereas mutations update data in the same way a POST, PUT or DELETE would do in a REST api.
All queries and mutations are sent via a HTTP POST request that must include a JSON body specifying the query
and any variables
needed for the server to fulfill your request.
Strong Types
One of the advantages of using GraphQL is that we must define and use strongly and constrained types to represent the data used across our app. This allows ensuring that a field that is marked as "required" will never be absent from an entity or that a type marked as a number will never fail to be numeric and therefore a developer can be sure to use them safely in their code.
For example, if you find the AdAccountInterface
type defined in the schema you will notice that fields like id
, name
and platform
are required and will always be provided:
interface AdAccountInterface {
platform: AdPlatform!
id: String!
inserted: Instant!
name: String!
timeZone: TimeZone!
currencyCode: CurrencyCode!
performanceDataAvailableFrom: LocalDate
flags: AdAccountFlags!
status: AdAccountStatus!
scans: [ScanReferenced!]!
insights: AdAccountInsights!
tags: [Tag!]!
}
Writing Your Query
To query for ad account id
, name
and platform
, you would write a query like this:
query fetchAdAccounts {
adAccounts(limit: 10, offset: 0){
nodes {
__typename
... on AdAccountInterface {
id
name
platform
}
}
}
}
Note how we are only requesting three fields from the AdAccountInterface
even though it has many more. This is another advantage that GraphQL offers: to request only the data needed to fulfill a requirement. Adpulse recommends that when running your queries, you only request the data that you plan to use.
Another important thing to note is that querying data from the Adpulse API offers several facilities to filter, sort, and paginate through your data sets, which is something that you will come across in most queries.
Run Your Query
In practice, you would use your programming language facilities to execute the query above; however, in this instance, we can use curl
for testing purposes:
curl -X POST ${ADPULSE_HOME}/graphql \
-H "Authorization: Bearer ${ADPULSE_TOKEN}" \
-H "Content-Type: application/json" \
--data @- << EOF
{
"query": "query fetchAdAccounts {
adAccounts(limit: 10, offset: 0){
nodes {
__typename
... on AdAccountInterface {
id
name
platform
}
}
}
}"
}
EOF
This would produce something like:
{
"data": {
"adAccounts": {
"nodes": [
{
"__typename": "AdAccountGoogle",
"id": "1234567890",
"name": "Mos Eisley Cantina",
"platform": "google"
},
{
"__typename": "AdAccountMicrosoft",
"id": "789064321",
"name": "Hunter's Guild Nevarro",
"platform": "microsoft"
}
]
}
}
}
In those results, we can see the __typename
varies depending on the platform to which an ad account belongs (which depends on the ad platforms you have connected and the accounts you have imported into Adpulse).
Go ahead and experiment querying specific fields for the more specific types. Hint use the ... on TypeName
syntax to access them, ie ... on AdAccountGoogle
or ... on AdAccountMicrosoft
Pagination, Sorting, and Filtering
For this section, we will explain how to use Adpulse GraphQL query facilities with the budgets data as a use case to answer the questions:
What is the best-performing budget in terms of pacing?
What is the worst?
Note: To keep this section simple and constrained, the GraphQL types mentioned are reduced to simple versions, removing details but keeping it accurate for the selected use case.
The Budget type is the result of a union of types, and it looks like this:
union Budget__new = BudgetCustomV2 | BudgetGoogleAccount
And some of the fields for the BudgetCustomV2
look like:
type BudgetCustomV2 {
currencyCode: CurrencyCode!
entries: BudgetEntries!
id: ID!
inserted: Instant!
name: String!
spend: BudgetCustomV2Spend!
status: BudgetStatus!
campaignBudgetGroup: CampaignBudgetGroup!
}
From there, we can inspect the entries
item and access its current
entry field that looks like the following:
type BudgetEntry {
clicks: Long!
conversions: Float!
id: ID!
impressions: Long!
lastUpdated: Instant!
pacing: BigDecimal
progress: BigDecimal
spend: BigDecimal!
}
The field we are interested in here is the pacing
field. See how pacing works here.
Connections and Pagination
In Adpulse, most datasets can be paginated through by using offset pagination, and a few others using cursor-based pagination. In general, the offset pagination data type looks like:
type ConnectionMyType {
nodes: [MyType!]!
offsetInfo: OffsetInfo!
total: Long
}
Where OffsetInfo
looks like:
type OffsetInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
}
Connection queries will always include a limit: Int
and an offset: Int
parameters that tell the server what records to return. Sensible defaults are in place, generally documented on each connection query.
For our use case, the connection that we need to query is the BudgetTreeConnection
, this connection, which holds nodes of type BudgetTree
which is another union of several budget types, the details for this guide are not too important; however, we encourage you to inspect the possible types and get an intuition of what they represent. For demonstration purposes, the budget tree type that we will use here is the BudgetTreeSingleRoot
which represents a root budget that has at least one child budget in it.
The query that gives us access to this connection looks like (some details omitted):
budgetTree(
"Maximum number of values is 1,000"
adAccountIds: [AdAccountIdInput!],
"Filter expression. Supported variables are 'name: String!'"
filter: String,
"Maximum allowed value is 100"
limit: Int,
"Maximum allowed value is 5,000"
offset: Int,
"Order the results by providing pairs of columns and sort directions"
order: [BudgetTreeConnectionOrder!],
platforms: [AdPlatform!],
): BudgetTreeConnection
A query fragment to get the pacing field from the root budget within the BudgetTreeSingleRoot
would look like:
fragment budgetTreeSingleRoot on BudgetTreeSingleRoot {
__typename
budget {
... on BudgetCustomV2 {
id
name
entries {
current {
pacing
}
}
campaignBudgetGroup {
adAccounts {
... on AdAccountInterface {
id
name
}
}
}
}
}
}
Take note of how the query definition includes several parameters relevant to the dataset in question. In Adpulse, all offset queries would include limit
, offset
and order
, whereas the filter
field would be in most of them.
Sorting
Now that we know the connection we need to use and the query that gives us access to it, we can proceed to write and execute a query to answer our questions.
Our full query would look like:
fragment budgetTreeSingleRoot on BudgetTreeSingleRoot {
__typename
budget {
... on BudgetCustomV2 {
id
name
entries {
current {
pacing
}
}
campaignBudgetGroup {
adAccounts {
... on AdAccountInterface {
id
name
}
}
}
}
}
}
query fetchBestBudgets(
$order: [BudgetTreeConnectionOrder!]
) {
budgetTree(limit: 5, order: $order, offset: 0, filter: null){
nodes {
__typename
... on BudgetTreeSingleRoot{
...budgetTreeSingleRoot
}
}
}
}
This queries for the top 5 budgets ordered according to what's specified by the $order
parameter. We'll use the pacing
field in this case.
Now, to be able to run this query using curl we need to json encode the query above and pass it to curl in an appropriate format, a query.json
file with the full query would look like:
{
"variables": {
"order": {
"column": "pacing",
"direction": "desc"
}
},
"query": "fragment budgetTreeSingleRoot on BudgetTreeSingleRoot {\n __typename\n budget {\n ... on BudgetCustomV2 {\n id\n name\n entries {\n current {\n target\n spend\n pacing\n }\n }\n campaignBudgetGroup {\n adAccounts {\n ... on AdAccountInterface {\n id\n name\n }\n }\n }\n }\n }\n }\n \n query fetchBestBudgets(\n $order: [BudgetTreeConnectionOrder!]\n ) {\n budgetTree(limit: 5, order: $order, offset: 0, filter: null){\n nodes {\n __typename\n ... on BudgetTreeSingleRoot{\n ...budgetTreeSingleRoot\n }\n }\n }\n }"
}
Then we can simply execute:
curl -X POST ${ADPULSE_HOME}/graphql \
-H "Authorization: Bearer ${ADPULSE_TOKEN}" \
-H "Content-Type: application/json" \
--data @query.json
This could return results similar to the following:
{
"data": {
"budgetTree": {
"nodes": [
{
"__typename": "BudgetTreeSingleRoot",
"budget": {
"id": "bdg_2y4jc6mlbvaalncohugziljdjm",
"name": "Mos Eisley Cantina - All Campaigns",
"entries": {
"current": {
"target": 220,
"spend": 55.35,
"pacing": 108.05438277104233
}
},
"campaignBudgetGroup": {
"adAccounts": [
{
"id": "1234567890",
"name": "Mos Eisley Cantina"
}
]
}
}
},
{
"__typename": "BudgetTreeSingleRoot",
"budget": {
"id": "bdg_cs7eyhkxu5fyzl73e2vi6tpuum",
"name": "Hunter's Guild Nevarro - All Campaigns",
"entries": {
"current": {
"target": 1000,
"spend": 74.793311,
"pacing": 49.92824999205298
}
},
"campaignBudgetGroup": {
"adAccounts": [
{
"id": "789064321",
"name": "Hunter's Guild Nevarro"
}
]
}
}
},
{
"__typename": "BudgetTreeClientAllSelectedBudget"
}
]
}
}
}
In this instance, the account has only three budgets, two of BudgetTreeSingleRoot
type and one of a legacy type BudgetTreeClientAllSelectedBudget
that we have chosen to ignore for this exercise.
Now, to answer the questions in this example is simple, given that we have only two budgets, one pacing at 108% and another one at 49.93%. And according to the definition of on-target pacing, the budget "Mos Eisley Cantina - All Campaigns" is the best-performing budget, and "Hunter's Guild Nevarro" is not doing too well.
Filtering
Whenever possible, Adpulse recommends filtering your data down to only the records you plan to use. To do that, most queries provide parameters to filter down as much as possible the records returned. Filter by ad platform, by ad account ids, and by tags are some of the most common filters provided.
In our use case, imagine we had hundreds of budgets, we would need to limit the data fetched somehow. For example, we could just query for budgets with google
ad accounts and perhaps containing "Mos Eisley" in their budget name. Tags are also a very good option for grouping and selecting records.
To filter down our request using platform
and budget name we can simply define the variables in our query:
query fetchBestBudgets(
$order: [BudgetTreeConnectionOrder!],
$filter: String!,
$plaforms: [AdPlatform!]
) {
budgetTree(limit: 5, order: $order, offset: 0, filter: $filter, platforms: $plaforms){
nodes {
__typename
... on BudgetTreeSingleRoot{
...budgetTreeSingleRoot
}
}
}
}
Define a filter
variable as follows:
{
"variables": {
"order": {
"column": "pacing",
"direction": "desc"
},
"filter": "contains(\"name\", \"Mos Eisley\")",
"platforms": ["google"]
},
"query": "fragment budgetTreeSingleRoot on BudgetTreeSingleRoot {\n __typename\n budget {\n ... on BudgetCustomV2 {\n id\n name\n entries {\n current {\n target\n spend\n pacing\n }\n }\n campaignBudgetGroup {\n kpi {\n __typename\n }\n adAccounts {\n ... on AdAccountInterface {\n id\n name\n }\n }\n }\n }\n }\n}\n\nquery fetchBestBudgets(\n $order: [BudgetTreeConnectionOrder!],\n $filter: String!,\n $plaforms: [AdPlatform!]\n) {\n budgetTree(limit: 5, order: $order, offset: 0, filter: $filter, platforms: $plaforms){\n nodes {\n __typename\n ... on BudgetTreeSingleRoot{\n ...budgetTreeSingleRoot\n }\n }\n }\n}"
}
This would narrow down the results to just one record from our sample dataset.
Tags Filtering
Tags are a very useful feature that allows filtering your data. The tag filter type looks like:
tagIds: [[ID!]!]
This means the tagIds take a bidimensional array of tag ids. Its functionality is as follows:
The outer array elements are joined in an
OR
operation. For example, if you want to fetch records that have the tag id 1, 2, or 3, you would set thetagIds
parameter to[[1], [2], [3]]
The inner array elements are joined with and
AND
operation, so if you want to fetch records that have the tag id 1 and 2, you would set the parameter to[[1, 2]]
.An empty array
tagIds = []
, means return records that do not have any tags attached to them.
There is also a notTagIds
parameter that allows you to exclude records by tags. The functionality is basically the negation of tagIds
.
Cursor-Based Pagination
Cursor-based pagination is a well-known standard when using GraphQL, in the case of Adpulse, there are a handful of queries that work using this mechanism. NoteConnection
and ChangeLogConnection
are good examples.
The types in Adpulse look like the following:
type ChangeLogConnection {
edges: [ChangeLogConnectionEdge!]!
pageInfo: PageInfo!
}
type ChangeLogConnectionEdge {
cursor: String!
node: ChangeLog!
}
type PageInfo {
endCursor: String!
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String!
}
The ChangeLog type is made up of a large union describing each change that may have occurred due to actions performed by your users. We are not going to go into the details, but go ahead and review the type for your own understanding.
To query for change logs, we need to use the changeLog
query that looks somewhat like the following:
changeLog(
"Maximum allowed value is 1,000"
adAccountIds: [AdAccountIdInput!],
after: String,
before: String,
"Maximum allowed value is 1,000"
budgetIds: [ID!],
"Filter expression. Supported variables are 'timestamp: Timestamp!', 'user.name: String!', 'user.email: String!'"
filter: String,
"Maximum allowed value is 500"
first: Int,
"Maximum allowed value is 500"
last: Int,
platforms: [AdPlatform!],
"To limit ad accounts with either tag id 1, 2, or 3: [[1], [2], [3]]. To limit ad accounts that have tag id 1 and 2, or 3: [[1, 2], 3]. To limit ad accounts with no tag: []."
tagIds: [[ID!]!],
types: [ChangeLogType!]
): ChangeLogConnection
A query to paginate from the most recent changes would look like:
fragment changeLogConnection on ChangeLogConnection {
edges {
cursor
node {
id
user {
name
}
entry {
__typename
}
}
}
pageInfo {
startCursor
endCursor
hasNextPage
hasPreviousPage
}
}
query fetchChangeLogs {
changeLog {
...changeLogConnection
}
}
Variables:
{
"first": 25
}
Running this query will return the newest 25 change log records. The edge field holds the node with the change log data, along with a cursor
(pointer) to itself. This pointer is special because it identifies the node across the dataset and can be used as the after
or before
parameter.
A possible response payload could look like the following (only the last edge showed):
{
"data": {
"changeLog": {
"pageInfo": {
"hasPreviousPage": false,
"hasNextPage": true,
"startCursor": "aWQ6MTAwMDk4Mw==",
"endCursor": "aWQ6MTAwMDk1OQ=="
},
"edges": [
{
"cursor": "aWQ6MTAwMDk1OQ==",
"node": {
"id": "1000959",
"user": {
"name": "Greef Karga"
},
"entry": {
"__typename": "ChangeLogEntryAdAccountLinked"
}
}
}
]
}
}
}
In our subsequent request to get the next "page" we need to use the endCursor
as our after
parameter so that Adpulse API correctly returns the subsequent page:
{
"first": 25,
"after": "aWQ6MTAwMDk1OQ=="
}
The next page could look like the following (only the first and last edge showed):
{
"data": {
"changeLog": {
"pageInfo": {
"hasPreviousPage": true,
"hasNextPage": true,
"startCursor": "aWQ6MTAwMDk1OA==",
"endCursor": "aWQ6MTAwMDkzNA=="
},
"edges": [
{
"cursor": "aWQ6MTAwMDk1OA==",
"node": {
"id": "1000958",
"user": {
"name": "Han"
},
"entry": {
"__typename": "ChangeLogEntryUserInvitationCreated"
}
}
},
{
"cursor": "aWQ6MTAwMDkzNA==",
"node": {
"id": "1000934",
"user": {
"name": "Fett"
},
"entry": {
"__typename": "ChangeLogEntryTagCreated"
}
}
}
]
}
}
}
Lastly, if we wanted to fetch the previous page (page 2) of data, then we would use the startCursor
as our before
parameter as follows, but we need to swap to ask for the last
25 items and not the first
(we need the last 25, because there are at least 50 records before page 3):
{
"last": 25,
"before": "aWQ6MTAwMDk1OA=="
}
Mutations
Updates can be performed using GraphQL mutations. They work in the same way as queries but their intention is to update data at the server side and they may not return data.
A simple mutation to update your preferred name in Adpulse would look like the following:
mutation updateMyPreferredName($preferredName: String) {
userCurrentUpdate(preferredName: $preferredName){
id
preferredName
}
}
Executing this query would look a lot like the queries we've seen before:
{
"variables": {
"preferredName": "Din"
},
"query":"mutation updateMyPreferredName($preferredName: String) {\n userCurrentUpdate(preferredName: $preferredName){\n id\n preferredName\n }\n}"
}
curl -X POST ${ADPULSE_HOME}/graphql \
-H "Authorization: Bearer ${ADPULSE_TOKEN}" \
-H "Content-Type: application/json" \
--data @query.json
In this instance the userCurrentUpdate
mutation returns the current user data as part of its response, so we would see a response similar to:
{
"data": {
"userCurrentUpdate": {
"id": "usr_a6zze6zwizghhk5cl2z5aoin3m",
"preferredName": "Din"
}
}
}
Schema Changes
Adpulse is committed to bringing the best features in the app to our customers. For the Adpulse API this means the schema evolves rapidly. New features are added constantly, and existing features are updated from time to time. You should be aware that a query or type that you rely on may change without notice. An effort is made to mark deprecated items as such and keep the schema backwards compatible for a period of time. However, there is a chance that breaking changes may be needed, so you should keep up to date with any schema changes that may accompany new or updated features in app.
A good trigger for you to check for any api updates is whenever a new release is announced on Adpulse release notes. We recommend you update the schema after every release or more often as you see fit.
Query Restrictions
Nested Queries
Please note that, given the nature of GraphQL, some queries may lead to deeply nested fields being requested, for example, a tag may be assigned to a user, and all users have a tag:
type User {
id: ID!
lastLogin: Instant
name: String!
preferredName: String
tag: Tag
}
type Tag {
color: String!
"1-255 characters"
description: String
id: ID!
isUserTag: Boolean!
"1-80 characters"
name: String!
user: User
}
A query for tags may request the user data that could be related to it:
query fetchTags {
tags{
nodes {
tag {
id
name
isUserTag
user {
id
name
}
}
}
}
}
This will be allowed, and no errors will be reported. Howeve,r nesting a request for the user tag will be blocked:
query fetchTags {
tags{
nodes {
tag {
id
name
isUserTag
user {
id
name
tag {
id
name
}
}
}
}
}
}
This query will be rejected with an error similar to:
{
"data": null,
"errors": [
{
"message": "Invalid path requested: already within tag",
"locations": [
{
"line": 54,
"column": 11
}
],
"extensions": {
"status": 400,
"error_id": null,
"code": "graphql_validation_error"
}
}
]
}
As a rule of thumb, a query that goes too deep and/or requests data that may be available at a previous level will fail validation.