Developers
Set your API contextPick a region and space ID once. Every sample fills in your base URL.
Where do I find these?

In Coevera: open your space's API application and choose Show Access. It shows the space ID and the base URL, whose host starts with your region.

From the API: if you know the space ID, the discovery endpoint GET /api/discovery/spaces/{space_id} returns its region and base URLs, without credentials.

Only the region and space ID are kept, in this browser. Never paste a key or password here.

More in the quickstart

RecipesLead lifecycle (GraphQL)

Lead lifecycle (GraphQL)

Create a lead, qualify it into an opportunity, move the opportunity to the next step and change its owner, with the GraphQL actions that REST does not have.

  • 15 min
  • GraphQL only
  • cURL · TypeScript · Python
  • Verified in the docs sandbox on 2026-10-15

Answer

Create the lead with createLead, then call qualifyLead with the lead's id and what an opportunity needs (stepId, closingDate, value): the lead becomes an opportunity with the same id. moveOpportunity changes the step and changeOwnershipOpportunity the owner and unit. These actions exist only in GraphQL; REST has no qualify call.

Fabrikam Robotics asks about line-side analytics. Record it as a lead, qualify the lead into an opportunity once the interest is real, move the opportunity to the Offer step, and hand it to its owner. You end with one opportunity that carries the lead's id.

This recipe is GraphQL only. REST has no call that qualifies a lead. A REST PATCH can change an opportunity's step_id or owner_id, but not with what the GraphQL actions add: moveOpportunity takes a memo, activities to complete and quotes to accept or archive, and changeOwnershipOpportunity takes keepOwnerAsEditor and transferLinkedRecords.

Before you start

Permissions Full Access to Leads and Opportunities, and Read Only Access to Accounts, on the Records tab; Read Only Access to Pipelines and Currencies on the Administration tab. The owner lookup also reads users (Clients).
validation-level The default (full validation).
Batch behaviour No batches.
Context Region and space ID come from the context bar. Credentials come from $COEVERA_API_KEY and $COEVERA_API_SECRET.

The programs need curl and jq, Node.js 18 or later, or Python 3.9 or later with requests. Authentication shows where the credentials come from, and Permissions and scopes where to set the permissions.

The operations this recipe calls, in the API reference: GraphQL ClientRepository.getByCriteria, AccountRepository.getByCriteria, StepRepository.getByCriteria, CurrencyRepository.getByCriteria, createLead, qualifyLead, moveOpportunity, changeOwnershipOpportunity, LeadRepository.getById, OpportunityRepository.getById.

Set up a helper

Every step below uses this setup. Export the credentials first:

Shell
export COEVERA_API_KEY="<application user name>"
export COEVERA_API_SECRET="<application password>"   # never commit it
COEVERA_GRAPHQL="https://{region}.api.coevera.com/api/v100/app/space/{space_id}/graphql/public"

# gql: POST the GraphQL request read from stdin. GraphQL reports errors in the body, often
# with HTTP 200, so fail on those too.
gql() {
  local response
  response=$(curl -sS --fail-with-body "$COEVERA_GRAPHQL" \
    -u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \
    -H "Content-Type: application/json" \
    -d @-)
  if jq -e '.errors' <<<"$response" >/dev/null; then
    echo "$response" >&2
    return 1
  fi
  echo "$response"
}
const endpoint = `https://{region}.api.coevera.com/api/v100/app/space/{space_id}/graphql/public`;
const auth = Buffer.from(`${env.COEVERA_API_KEY}:${env.COEVERA_API_SECRET}`).toString("base64");

// gql: one GraphQL request. GraphQL reports errors in the body, often with HTTP 200.
async function gql(query: string, variables: object = {}) {
  const res = await fetch(endpoint, {
    method: "POST",
    headers: { Authorization: `Basic ${auth}`, "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  const body = await res.json();
  if (!res.ok || body.errors) throw new Error(`HTTP ${res.status}: ${JSON.stringify(body.errors ?? body)}`);
  return body.data;
}
ENDPOINT = (
    "https://{region}.api.coevera.com/api/v100/app/space/"
    "{space_id}/graphql/public"
)
session = requests.Session()
session.auth = (os.environ["COEVERA_API_KEY"], os.environ["COEVERA_API_SECRET"])


def gql(query, variables=None):
    """One GraphQL request. GraphQL reports errors in the body, often with HTTP 200."""
    response = session.post(ENDPOINT, json={"query": query, "variables": variables or {}}, timeout=30)
    body = response.json()
    if not response.ok or body.get("errors"):
        sys.exit(f"HTTP {response.status_code}: {json.dumps(body.get('errors', body))}")
    return body["data"]

GraphQL only: this sample has no REST version.

1. Look everything up in one query

GraphQL reads several repositories in one request. The program asks for the owner and their sales unit, the account, the pipeline's first step, the Offer step and the base currency. The two step lookups use the same repository, so they carry aliases: firstStep and offerStep.

# Five lookups in one request, two of them on the same repository under aliases: the owner
# and their sales unit, the account, the pipeline's first step, the step to move to, and the
# base currency.
found=$(jq -n '{
  query: "{ entities { client { getByCriteria(first: 1, filter: { email: { eq: [\"dana.whitfield@example.com\"] } }) { edges { node { id name defaultUnitId } } } } account { getByCriteria(first: 1, filter: { email1: { eq: [\"fabrikam@example.com\"] } }) { edges { node { id name } } } } firstStep: step { getByCriteria(first: 1, filter: { pipeline: { name: { eq: [\"Default pipeline\"] } } }, orderBy: [{ sortOrder: Asc }]) { edges { node { id name } } } } offerStep: step { getByCriteria(first: 1, filter: { pipeline: { name: { eq: [\"Default pipeline\"] } }, name: { eq: [\"Offer\"] } }) { edges { node { id name } } } } currency { getByCriteria(first: 1, filter: { isBase: true }) { edges { node { id code } } } } } }"
}' | gql)
echo "$found"
OWNER_ID=$(jq -er '.data.entities.client.getByCriteria.edges[0].node.id' <<<"$found")
UNIT_ID=$(jq -er '.data.entities.client.getByCriteria.edges[0].node.defaultUnitId' <<<"$found")
ACCOUNT_ID=$(jq -er '.data.entities.account.getByCriteria.edges[0].node.id' <<<"$found")
FIRST_STEP_ID=$(jq -er '.data.entities.firstStep.getByCriteria.edges[0].node.id' <<<"$found")
NEXT_STEP_ID=$(jq -er '.data.entities.offerStep.getByCriteria.edges[0].node.id' <<<"$found")
CURRENCY_ID=$(jq -er '.data.entities.currency.getByCriteria.edges[0].node.id' <<<"$found")
// Five lookups in one request, two of them on the same repository under aliases: the owner
// and their sales unit, the account, the pipeline's first step, the step to move to, and the
// base currency.
const found = await gql(`{
  entities {
    client {
      getByCriteria(first: 1, filter: { email: { eq: ["dana.whitfield@example.com"] } }) {
        edges { node { id name defaultUnitId } }
      }
    }
    account {
      getByCriteria(first: 1, filter: { email1: { eq: ["fabrikam@example.com"] } }) {
        edges { node { id name } }
      }
    }
    firstStep: step {
      getByCriteria(first: 1, filter: { pipeline: { name: { eq: ["Default pipeline"] } } }, orderBy: [{ sortOrder: Asc }]) {
        edges { node { id name } }
      }
    }
    offerStep: step {
      getByCriteria(first: 1, filter: { pipeline: { name: { eq: ["Default pipeline"] } }, name: { eq: ["Offer"] } }) {
        edges { node { id name } }
      }
    }
    currency {
      getByCriteria(first: 1, filter: { isBase: true }) { edges { node { id code } } }
    }
  }
}`);
const owner = found.entities.client.getByCriteria.edges[0]?.node;
const account = found.entities.account.getByCriteria.edges[0]?.node;
const firstStep = found.entities.firstStep.getByCriteria.edges[0]?.node;
const offerStep = found.entities.offerStep.getByCriteria.edges[0]?.node;
const currency = found.entities.currency.getByCriteria.edges[0]?.node;
if (!owner || !account || !firstStep || !offerStep || !currency) {
  throw new Error("A lookup found nothing: check the e-mails, the pipeline and the step names.");
}
console.log("Owner:", owner.name, "account:", account.name, "steps:", firstStep.name, "then", offerStep.name);
# Five lookups in one request, two of them on the same repository under aliases: the owner
# and their sales unit, the account, the pipeline's first step, the step to move to, and the
# base currency.
found = gql("""
{
  entities {
    client {
      getByCriteria(first: 1, filter: { email: { eq: ["dana.whitfield@example.com"] } }) {
        edges { node { id name defaultUnitId } }
      }
    }
    account {
      getByCriteria(first: 1, filter: { email1: { eq: ["fabrikam@example.com"] } }) {
        edges { node { id name } }
      }
    }
    firstStep: step {
      getByCriteria(first: 1, filter: { pipeline: { name: { eq: ["Default pipeline"] } } }, orderBy: [{ sortOrder: Asc }]) {
        edges { node { id name } }
      }
    }
    offerStep: step {
      getByCriteria(first: 1, filter: { pipeline: { name: { eq: ["Default pipeline"] } }, name: { eq: ["Offer"] } }) {
        edges { node { id name } }
      }
    }
    currency {
      getByCriteria(first: 1, filter: { isBase: true }) { edges { node { id code } } }
    }
  }
}
""")["entities"]
lookups = {key: found[key]["getByCriteria"]["edges"] for key in ("client", "account", "firstStep", "offerStep", "currency")}
if not all(lookups.values()):
    sys.exit("A lookup found nothing: check the e-mails, the pipeline and the step names.")
owner, account, first_step, offer_step, currency = (edges[0]["node"] for edges in lookups.values())
print("Owner:", owner["name"], "account:", account["name"], "steps:", first_step["name"], "then", offer_step["name"])

GraphQL only: this sample has no REST version.

200 OKRecorded response · 2026-10-15 · docs sandbox, de-identified
{
  "data": {
    "entities": {
      "client": {
        "getByCriteria": {
          "edges": [
            {
              "node": {
                "id": "0d000000-0000-4000-a000-000000000001",
                "name": "Dana Whitfield",
                "defaultUnitId": "05000000-0000-4000-a000-000000000001"
              }
            }
          ]
        }
      },
      "account": {
        "getByCriteria": {
          "edges": [
            {
              "node": {
                "id": "0a000000-0000-4000-a000-000000000001",
                "name": "Fabrikam Robotics"
              }
            }
          ]
        }
      },
      "firstStep": {
        "getByCriteria": {
          "edges": [
            {
              "node": {
                "id": "07000000-0000-4000-a000-000000000001",
                "name": "Validate"
              }
            }
          ]
        }
      },
      "offerStep": {
        "getByCriteria": {
          "edges": [
            {
              "node": {
                "id": "07000000-0000-4000-a000-000000000002",
                "name": "Offer"
              }
            }
          ]
        }
      },
      "currency": {
        "getByCriteria": {
          "edges": [
            {
              "node": {
                "id": "04000000-0000-4000-a000-000000000001",
                "code": "USD"
              }
            }
          ]
        }
      }
    }
  }
}
Show all 67 linesShow less

2. Create the lead

A lead needs only a name. The program gives it an owner and links the account.

lead=$(jq -n --arg owner "$OWNER_ID" --arg account "$ACCOUNT_ID" '{
  query: "mutation CreateLead($input: CreateLeadInput!) { createLead(input: $input) { lead { id name status } } }",
  variables: {
    input: {
      name: "Fabrikam line-side analytics",
      ownerId: $owner,
      accountRelations: [{ accountId: $account, isPrimary: true }]
    }
  }
}' | gql)
echo "$lead"
LEAD_ID=$(jq -er '.data.createLead.lead.id' <<<"$lead")
const created = await gql(
  `mutation CreateLead($input: CreateLeadInput!) {
    createLead(input: $input) { lead { id name status } }
  }`,
  {
    input: {
      name: "Fabrikam line-side analytics",
      ownerId: owner.id,
      accountRelations: [{ accountId: account.id, isPrimary: true }],
    },
  },
);
const leadId: string = created.createLead.lead.id;
console.log("Lead:", leadId);
created = gql(
    """
    mutation CreateLead($input: CreateLeadInput!) {
      createLead(input: $input) { lead { id name status } }
    }
    """,
    {
        "input": {
            "name": "Fabrikam line-side analytics",
            "ownerId": owner["id"],
            "accountRelations": [{"accountId": account["id"], "isPrimary": True}],
        }
    },
)
lead_id = created["createLead"]["lead"]["id"]
print("Lead:", lead_id)

GraphQL only: this sample has no REST version.

200 OKRecorded response · 2026-10-15 · docs sandbox, de-identified
{
  "data": {
    "createLead": {
      "lead": {
        "id": "08000000-0000-4000-a000-000000000001",
        "name": "Fabrikam line-side analytics",
        "status": "Open"
      }
    }
  }
}

3. Qualify it

qualifyLead takes the lead's id in entity, with what an opportunity needs that a lead does not have: a stepId in the pipeline, a closingDate and a value. The result is an opportunity with the same id, wasQualified: true, and the lead's account.

# Qualifying turns the lead into an opportunity with the same id: give it what an opportunity
# needs, a step of the pipeline, a closing date and a value.
jq -n --arg id "$LEAD_ID" --arg step "$FIRST_STEP_ID" --arg currency "$CURRENCY_ID" '{
  query: "mutation Qualify($input: QualifyLeadInput!) { qualifyLead(input: $input) { result { id status wasQualified step { name } } } }",
  variables: {
    input: {
      entity: {
        id: $id, stepId: $step, closingDate: "2027-02-26",
        value: { baseValue: 26000, currencyId: $currency }
      }
    }
  }
}' | gql
// Qualifying turns the lead into an opportunity with the same id: give it what an opportunity
// needs, a step of the pipeline, a closing date and a value.
const qualified = await gql(
  `mutation Qualify($input: QualifyLeadInput!) {
    qualifyLead(input: $input) { result { id status wasQualified step { name } } }
  }`,
  {
    input: {
      entity: {
        id: leadId,
        stepId: firstStep.id,
        closingDate: "2027-02-26",
        value: { baseValue: 26000, currencyId: currency.id },
      },
    },
  },
);
console.log("Opportunity:", JSON.stringify(qualified.qualifyLead.result));
# Qualifying turns the lead into an opportunity with the same id: give it what an opportunity
# needs, a step of the pipeline, a closing date and a value.
qualified = gql(
    """
    mutation Qualify($input: QualifyLeadInput!) {
      qualifyLead(input: $input) { result { id status wasQualified step { name } } }
    }
    """,
    {
        "input": {
            "entity": {
                "id": lead_id,
                "stepId": first_step["id"],
                "closingDate": "2027-02-26",
                "value": {"baseValue": 26000, "currencyId": currency["id"]},
            }
        }
    },
)
print("Opportunity:", json.dumps(qualified["qualifyLead"]["result"]))

GraphQL only: this sample has no REST version.

200 OKRecorded response · 2026-10-15 · docs sandbox, de-identified
{
  "data": {
    "qualifyLead": {
      "result": {
        "id": "08000000-0000-4000-a000-000000000001",
        "status": "Open",
        "wasQualified": true,
        "step": {
          "name": "Validate"
        }
      }
    }
  }
}

4. Move it to the next step

moveOpportunity takes the opportunity's id and the new stepId in entity. The same call can add a memo (memoText), complete activities (completeActivityIds) and accept or archive quotes (acceptQuoteIds, archiveQuoteIds).

# The move action; moveOpportunity can also add a memo, complete activities and accept or
# archive quotes in the same call.
jq -n --arg id "$LEAD_ID" --arg step "$NEXT_STEP_ID" '{
  query: "mutation Move($input: MoveOpportunityInput!) { moveOpportunity(input: $input) { result { id step { name } } } }",
  variables: { input: { entity: { id: $id, stepId: $step } } }
}' | gql
// The move action; moveOpportunity can also add a memo, complete activities and accept or
// archive quotes in the same call.
const moved = await gql(
  `mutation Move($input: MoveOpportunityInput!) {
    moveOpportunity(input: $input) { result { id step { name } } }
  }`,
  { input: { entity: { id: leadId, stepId: offerStep.id } } },
);
console.log("Moved to:", moved.moveOpportunity.result.step.name);
# The move action; moveOpportunity can also add a memo, complete activities and accept or
# archive quotes in the same call.
moved = gql(
    """
    mutation Move($input: MoveOpportunityInput!) {
      moveOpportunity(input: $input) { result { id step { name } } }
    }
    """,
    {"input": {"entity": {"id": lead_id, "stepId": offer_step["id"]}}},
)
print("Moved to:", moved["moveOpportunity"]["result"]["step"]["name"])

GraphQL only: this sample has no REST version.

200 OKRecorded response · 2026-10-15 · docs sandbox, de-identified
{
  "data": {
    "moveOpportunity": {
      "result": {
        "id": "08000000-0000-4000-a000-000000000001",
        "step": {
          "name": "Offer"
        }
      }
    }
  }
}

5. Change the owner

changeOwnershipOpportunity takes the new ownerId and unitId, and two choices: whether the old owner stays an editor (keepOwnerAsEditor) and whether the linked records move with it (transferLinkedRecords). All four are required. The program hands the opportunity to the same rep, so it runs in a space with one user; in your space, pass the new owner and their unit.

# The new owner and their sales unit. The docs sandbox has one user, so the script hands the
# opportunity to the same rep; pass the new owner's id and unit in your space.
jq -n --arg id "$LEAD_ID" --arg owner "$OWNER_ID" --arg unit "$UNIT_ID" '{
  query: "mutation ChangeOwner($input: ChangeOwnershipOpportunityInput!) { changeOwnershipOpportunity(input: $input) { result { id owner { name } } } }",
  variables: { input: { id: $id, ownerId: $owner, unitId: $unit, keepOwnerAsEditor: false, transferLinkedRecords: false } }
}' | gql
// The new owner and their sales unit. The docs sandbox has one user, so the program hands the
// opportunity to the same rep; pass the new owner's id and unit in your space.
const changed = await gql(
  `mutation ChangeOwner($input: ChangeOwnershipOpportunityInput!) {
    changeOwnershipOpportunity(input: $input) { result { id owner { name } } }
  }`,
  { input: { id: leadId, ownerId: owner.id, unitId: owner.defaultUnitId, keepOwnerAsEditor: false, transferLinkedRecords: false } },
);
console.log("Owner:", changed.changeOwnershipOpportunity.result.owner.name);
# The new owner and their sales unit. The docs sandbox has one user, so the program hands the
# opportunity to the same rep; pass the new owner's id and unit in your space.
changed = gql(
    """
    mutation ChangeOwner($input: ChangeOwnershipOpportunityInput!) {
      changeOwnershipOpportunity(input: $input) { result { id owner { name } } }
    }
    """,
    {
        "input": {
            "id": lead_id,
            "ownerId": owner["id"],
            "unitId": owner["defaultUnitId"],
            "keepOwnerAsEditor": False,
            "transferLinkedRecords": False,
        }
    },
)
print("Owner:", changed["changeOwnershipOpportunity"]["result"]["owner"]["name"])

GraphQL only: this sample has no REST version.

200 OKRecorded response · 2026-10-15 · docs sandbox, de-identified
{
  "data": {
    "changeOwnershipOpportunity": {
      "result": {
        "id": "08000000-0000-4000-a000-000000000001",
        "owner": {
          "name": "Dana Whitfield"
        }
      }
    }
  }
}

6. Read it back

Read the id back as a lead and as an opportunity. The lead is gone (null); the opportunity carries its id, the account from the lead, and the step, value and closing date the program set.

# The lead is gone; the opportunity carries its id.
jq -n --arg id "$LEAD_ID" '{
  query: "query ReadBack($id: ID!) { entities { lead { getById(entityId: $id) { id } } opportunity { getById(entityId: $id) { id name status wasQualified closingDate value { baseValue } step { name } owner { name } primaryAccount { name } } } } }",
  variables: { id: $id }
}' | gql
// The lead is gone; the opportunity carries its id.
const readBack = await gql(
  `query ReadBack($id: ID!) {
    entities {
      lead { getById(entityId: $id) { id } }
      opportunity {
        getById(entityId: $id) {
          id name status wasQualified closingDate
          value { baseValue }
          step { name }
          owner { name }
          primaryAccount { name }
        }
      }
    }
  }`,
  { id: leadId },
);
console.log(JSON.stringify(readBack.entities, null, 2));
# The lead is gone; the opportunity carries its id.
read_back = gql(
    """
    query ReadBack($id: ID!) {
      entities {
        lead { getById(entityId: $id) { id } }
        opportunity {
          getById(entityId: $id) {
            id name status wasQualified closingDate
            value { baseValue }
            step { name }
            owner { name }
            primaryAccount { name }
          }
        }
      }
    }
    """,
    {"id": lead_id},
)
print(json.dumps(read_back["entities"], indent=2))

GraphQL only: this sample has no REST version.

200 OKRecorded response · 2026-10-15 · docs sandbox, de-identified
{
  "data": {
    "entities": {
      "lead": {
        "getById": null
      },
      "opportunity": {
        "getById": {
          "id": "08000000-0000-4000-a000-000000000001",
          "name": "Fabrikam line-side analytics",
          "status": "Open",
          "wasQualified": true,
          "closingDate": "2027-02-26",
          "value": {
            "baseValue": 26000
          },
          "step": {
            "name": "Offer"
          },
          "owner": {
            "name": "Dana Whitfield"
          },
          "primaryAccount": {
            "name": "Fabrikam Robotics"
          }
        }
      }
    }
  }
}
Show all 30 linesShow less

What to check

  • Step 1 returns one node for each lookup. An empty edges names the lookup that found nothing.
  • Step 3 returns the lead's own id with wasQualified: true: keep using that id.
  • Step 4 returns the Offer step, and step 5 the owner.
  • Step 6 returns null for the lead and the opportunity with status: Open.
  • Run the program twice and you get two opportunities. To make it safe to repeat, look the lead up by name before you create it.

Clean up

The program leaves one opportunity in your space. Delete it, then its row in LeadOpptyAccountRelations (filter on lead_oppty_id): deleting an opportunity leaves the row active.

Limits

  • An opportunity in the pipeline's last step is won (status: Won), as the pipeline report shows. archiveOpportunity archives one; this recipe does not exercise it.
  • The program qualifies one lead. bulkQualifyLead and bulkMoveOpportunity exist for many; this recipe does not exercise them.
Verified on 2026-10-15 against API 6.5Behaviour-verified

Every sample on this page ran against the docs sandbox and returned the response shown.