RecipesLead from a web form
Lead from a web form
Turn a form submission into a lead from your own server, with the visitor's details in the quick fields, an owner and a lead source, in REST or GraphQL.
Answer
On your server, never in the browser, create a lead with name, an owner_id (or a unit_id), the visitor's details in quick_contact_name, quick_email, quick_account_name and quick_phone, the message in description and a lead_source_id looked up in Data. No contact or account is created. Encode the e-mail when you search by it: a raw + becomes a space.
Jordan Reyes of Contoso Freight fills in the demo-request form on Northwind's website. Your form handler turns the submission into a lead for the sales rep, with the channel it came from, and the rep qualifies it later (see Lead lifecycle).
The form posts to your own server, and the server calls the API. Never call the API from the page itself: the application's credentials would be in every visitor's browser.
Before you start
| Permissions | Full Access to Leads on the Records tab, and Read Only Access to Common Lists on the Administration tab for the lead source. The owner lookup also reads users (Clients). |
|---|---|
validation-level |
The default (0, full validation). |
| Batch behaviour | No batches: every write is one record. |
| Context | Region and space ID come from the context bar. Credentials come from $COEVERA_ and $COEVERA_. |
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: REST
GET /entities/Clients,
GET /entities/Data,
POST /entities/Leads,
GET /entities/Leads; GraphQL
ClientRepository.getByCriteria,
DataRepository.getByCriteria,
createLead,
LeadRepository.getById.
Set up a helper
Every step below uses this setup, and the submission as your form handler receives it. Export the credentials first:
export COEVERA_API_KEY="<application user name>"
export COEVERA_API_SECRET="<application password>" # never commit itCOEVERA_BASE="https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}"
# The submission, as your form handler receives it.
FORM_NAME="Jordan Reyes"
FORM_EMAIL="jordan.reyes+demo@example.com"
FORM_COMPANY="Contoso Freight"
FORM_PHONE="+1 206 555 0115"
FORM_MESSAGE="We run 40 trucks and want dashboards for fuel use."const base = `https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}`;
const auth = Buffer.from(`${env.COEVERA_API_KEY}:${env.COEVERA_API_SECRET}`).toString("base64");
async function coevera(path: string, init: { method?: string; body?: unknown } = {}) {
const res = await fetch(`${base}${path}`, {
method: init.method ?? "GET",
headers: { Authorization: `Basic ${auth}`, "Content-Type": "application/json" },
body: init.body === undefined ? undefined : JSON.stringify(init.body),
});
const body = await res.json();
if (!res.ok) throw new Error(`HTTP ${res.status} ${body.name}: ${String(body.message).trim()}`);
return body;
}
// The submission, as your form handler receives it.
const form = {
name: "Jordan Reyes",
email: "jordan.reyes+demo@example.com",
company: "Contoso Freight",
phone: "+1 206 555 0115",
message: "We run 40 trucks and want dashboards for fuel use.",
};BASE = "https://{region}.api.coevera.com/api/v100/rest/spaces/{space_id}"
session = requests.Session()
session.auth = (os.environ["COEVERA_API_KEY"], os.environ["COEVERA_API_SECRET"])
def coevera(method, path, **kwargs):
response = session.request(method, f"{BASE}{path}", timeout=30, **kwargs)
body = response.json()
if not response.ok:
sys.exit(f"HTTP {response.status_code} {body.get('name')}: {str(body.get('message')).strip()}")
return body
# The submission, as your form handler receives it.
form = {
"name": "Jordan Reyes",
"email": "jordan.reyes+demo@example.com",
"company": "Contoso Freight",
"phone": "+1 206 555 0115",
"message": "We run 40 trucks and want dashboards for fuel use.",
}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"
}
# The submission, as your form handler receives it.
FORM_NAME="Jordan Reyes"
FORM_EMAIL="jordan.reyes+demo@example.com"
FORM_COMPANY="Contoso Freight"
FORM_PHONE="+1 206 555 0115"
FORM_MESSAGE="We run 40 trucks and want dashboards for fuel use."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;
}
// The submission, as your form handler receives it.
const form = {
name: "Jordan Reyes",
email: "jordan.reyes+demo@example.com",
company: "Contoso Freight",
phone: "+1 206 555 0115",
message: "We run 40 trucks and want dashboards for fuel use.",
};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"]
# The submission, as your form handler receives it.
form = {
"name": "Jordan Reyes",
"email": "jordan.reyes+demo@example.com",
"company": "Contoso Freight",
"phone": "+1 206 555 0115",
"message": "We run 40 trucks and want dashboards for fuel use.",
}1. Find the owner
Decide who gets web leads; here, one sales rep, looked up by e-mail.
# Who gets web leads: here one sales rep.
owner=$(curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Clients" \
--data-urlencode "filter[email]=dana.whitfield@example.com" \
-d load-only=id,name \
-u "$COEVERA_API_KEY:$COEVERA_API_SECRET")
echo "$owner"
OWNER_ID=$(jq -er '.data[0].id' <<<"$owner")// Who gets web leads: here one sales rep.
const owners = await coevera(
`/entities/Clients?${new URLSearchParams({ "filter[email]": "dana.whitfield@example.com", "load-only": "id,name" })}`,
);
const ownerId: string | undefined = owners.data[0]?.id;
if (!ownerId) throw new Error("No user with that e-mail in this space.");
console.log("Owner:", owners.data[0].name, ownerId);# Who gets web leads: here one sales rep.
owners = coevera(
"GET",
"/entities/Clients",
params={"filter[email]": "dana.whitfield@example.com", "load-only": "id,name"},
)
if not owners["data"]:
sys.exit("No user with that e-mail in this space.")
owner_id = owners["data"][0]["id"]
print("Owner:", owners["data"][0]["name"], owner_id)# Who gets web leads: here one sales rep.
owner=$(jq -n '{
query: "{ entities { client { getByCriteria(first: 1, filter: { email: { eq: [\"dana.whitfield@example.com\"] } }) { edges { node { id name } } } } } }"
}' | gql)
echo "$owner"
OWNER_ID=$(jq -er '.data.entities.client.getByCriteria.edges[0].node.id' <<<"$owner")// Who gets web leads: here one sales rep.
const owners = await gql(`{
entities {
client {
getByCriteria(first: 1, filter: { email: { eq: ["dana.whitfield@example.com"] } }) {
edges { node { id name } }
}
}
}
}`);
const owner = owners.entities.client.getByCriteria.edges[0]?.node;
if (!owner) throw new Error("No user with that e-mail in this space.");
console.log("Owner:", owner.name, owner.id);# Who gets web leads: here one sales rep.
owners = gql("""
{
entities {
client {
getByCriteria(first: 1, filter: { email: { eq: ["dana.whitfield@example.com"] } }) {
edges { node { id name } }
}
}
}
}
""")["entities"]["client"]["getByCriteria"]["edges"]
if not owners:
sys.exit("No user with that e-mail in this space.")
owner = owners[0]["node"]
print("Owner:", owner["name"], owner["id"]){
"success": true,
"total": 1,
"page_info": {
"start_cursor": "WyIwZDAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==",
"end_cursor": "WyIwZDAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==",
"has_previous_page": false,
"has_next_page": false
},
"data": [
{
"id": "0d000000-0000-4000-a000-000000000001",
"name": "Dana Whitfield"
}
]
}{
"data": {
"entities": {
"client": {
"getByCriteria": {
"edges": [
{
"node": {
"id": "0d000000-0000-4000-a000-000000000001",
"name": "Dana Whitfield"
}
}
]
}
}
}
}
}2. Find the lead source
The lead source is a dropdown option, stored in Data: look its id up by the option's name.
The program looks up Direct traffic, one of options such as Organic search, Paid ad and Referral;
yours may differ. An option name can repeat in another dropdown, so check the one you get.
# The lead source is a dropdown option: look its id up by the option's name.
source=$(curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Data" \
--data-urlencode "filter[option_name]=Direct traffic" \
-d load-only=id,option_name \
-u "$COEVERA_API_KEY:$COEVERA_API_SECRET")
echo "$source"
LEAD_SOURCE_ID=$(jq -er '.data[0].id' <<<"$source")// The lead source is a dropdown option: look its id up by the option's name.
const sources = await coevera(`/entities/Data?${new URLSearchParams({ "filter[option_name]": "Direct traffic", "load-only": "id,option_name" })}`);
const leadSourceId: string | undefined = sources.data[0]?.id;
if (!leadSourceId) throw new Error("No lead source with that name.");
console.log("Lead source:", sources.data[0].option_name, leadSourceId);# The lead source is a dropdown option: look its id up by the option's name.
sources = coevera(
"GET", "/entities/Data", params={"filter[option_name]": "Direct traffic", "load-only": "id,option_name"}
)
if not sources["data"]:
sys.exit("No lead source with that name.")
lead_source_id = sources["data"][0]["id"]
print("Lead source:", sources["data"][0]["option_name"], lead_source_id)# The lead source is a dropdown option: look its id up by the option's name.
source=$(jq -n '{
query: "{ entities { data { getByCriteria(first: 1, filter: { optionName: { eq: [\"Direct traffic\"] } }) { edges { node { id optionName } } } } } }"
}' | gql)
echo "$source"
LEAD_SOURCE_ID=$(jq -er '.data.entities.data.getByCriteria.edges[0].node.id' <<<"$source")// The lead source is a dropdown option: look its id up by the option's name.
const sources = await gql(`{
entities {
data {
getByCriteria(first: 1, filter: { optionName: { eq: ["Direct traffic"] } }) {
edges { node { id optionName } }
}
}
}
}`);
const source = sources.entities.data.getByCriteria.edges[0]?.node;
if (!source) throw new Error("No lead source with that name.");
console.log("Lead source:", source.optionName, source.id);# The lead source is a dropdown option: look its id up by the option's name.
sources = gql("""
{
entities {
data {
getByCriteria(first: 1, filter: { optionName: { eq: ["Direct traffic"] } }) {
edges { node { id optionName } }
}
}
}
}
""")["entities"]["data"]["getByCriteria"]["edges"]
if not sources:
sys.exit("No lead source with that name.")
source = sources[0]["node"]
print("Lead source:", source["optionName"], source["id"]){
"success": true,
"total": 1,
"page_info": {
"start_cursor": "WyIxOTAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==",
"end_cursor": "WyIxOTAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==",
"has_previous_page": false,
"has_next_page": false
},
"data": [
{
"id": "19000000-0000-4000-a000-000000000001",
"option_name": "Direct traffic"
}
]
}{
"data": {
"entities": {
"data": {
"getByCriteria": {
"edges": [
{
"node": {
"id": "00000000-0000-4000-a000-000000000001",
"optionName": "Direct traffic"
}
}
]
}
}
}
}
}3. Create the lead
A lead needs a name and a sales unit. Send owner_id, and the lead takes the owner's unit; or
send only unit_id, and the lead waits unassigned in that unit. Without either, the create
answers 422 (Leads reference).
The visitor's details go into the quick fields, quick_contact_name, quick_email,
quick_account_name and quick_phone, and the message into description. The quick fields are
text on the lead: no contact or account is created.
# The visitor's details go into the quick fields: no contact or account is created yet. jq
# builds the body, so quotes in the visitor's message cannot break the JSON.
lead=$(jq -n --arg owner "$OWNER_ID" --arg source "$LEAD_SOURCE_ID" \
--arg name "$FORM_NAME" --arg email "$FORM_EMAIL" --arg company "$FORM_COMPANY" \
--arg phone "$FORM_PHONE" --arg message "$FORM_MESSAGE" \
'{
name: "\($company): demo request", owner_id: $owner, lead_source_id: $source,
quick_contact_name: $name, quick_email: $email, quick_account_name: $company,
quick_phone: $phone, description: $message
}' |
curl -sS --fail-with-body "$COEVERA_BASE/entities/Leads" \
-u "$COEVERA_API_KEY:$COEVERA_API_SECRET" \
-H "Content-Type: application/json" \
-d @-)
echo "$lead"
LEAD_ID=$(jq -er '.data.id' <<<"$lead")// The visitor's details go into the quick fields: no contact or account is created yet.
const lead = await coevera("/entities/Leads", {
method: "POST",
body: {
name: `${form.company}: demo request`,
owner_id: ownerId,
lead_source_id: leadSourceId,
quick_contact_name: form.name,
quick_email: form.email,
quick_account_name: form.company,
quick_phone: form.phone,
description: form.message,
},
});
const leadId: string = lead.data.id;
console.log("Lead:", leadId);# The visitor's details go into the quick fields: no contact or account is created yet.
lead = coevera(
"POST",
"/entities/Leads",
json={
"name": f"{form['company']}: demo request",
"owner_id": owner_id,
"lead_source_id": lead_source_id,
"quick_contact_name": form["name"],
"quick_email": form["email"],
"quick_account_name": form["company"],
"quick_phone": form["phone"],
"description": form["message"],
},
)
lead_id = lead["data"]["id"]
print("Lead:", lead_id)# The visitor's details go into the quick fields: no contact or account is created yet.
lead=$(jq -n --arg owner "$OWNER_ID" --arg source "$LEAD_SOURCE_ID" \
--arg name "$FORM_NAME" --arg email "$FORM_EMAIL" --arg company "$FORM_COMPANY" \
--arg phone "$FORM_PHONE" --arg message "$FORM_MESSAGE" '{
query: "mutation CreateLead($input: CreateLeadInput!) { createLead(input: $input) { lead { id } } }",
variables: {
input: {
name: "\($company): demo request", ownerId: $owner, leadSourceId: $source,
quickContactName: $name, quickEmail: $email, quickAccountName: $company,
quickPhone: $phone, description: $message
}
}
}' | gql)
echo "$lead"
LEAD_ID=$(jq -er '.data.createLead.lead.id' <<<"$lead")// The visitor's details go into the quick fields: no contact or account is created yet.
const created = await gql(
`mutation CreateLead($input: CreateLeadInput!) {
createLead(input: $input) { lead { id } }
}`,
{
input: {
name: `${form.company}: demo request`,
ownerId: owner.id,
leadSourceId: source.id,
quickContactName: form.name,
quickEmail: form.email,
quickAccountName: form.company,
quickPhone: form.phone,
description: form.message,
},
},
);
const leadId: string = created.createLead.lead.id;
console.log("Lead:", leadId);# The visitor's details go into the quick fields: no contact or account is created yet.
created = gql(
"""
mutation CreateLead($input: CreateLeadInput!) {
createLead(input: $input) { lead { id } }
}
""",
{
"input": {
"name": f"{form['company']}: demo request",
"ownerId": owner["id"],
"leadSourceId": source["id"],
"quickContactName": form["name"],
"quickEmail": form["email"],
"quickAccountName": form["company"],
"quickPhone": form["phone"],
"description": form["message"],
}
},
)
lead_id = created["createLead"]["lead"]["id"]
print("Lead:", lead_id){
"success": true,
"data": {
"is_delete_protected": false,
"id": "08000000-0000-4000-a000-000000000001",
"is_deleted": false,
"modified": "2026-10-15 09:00:00.000000+00:00",
"created": "2026-10-15 09:00:00.000047+00:00",
"currency_exchange_rates_list": null,
"lead_source": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/Data/19000000-0000-4000-a000-000000000001",
"lead_type": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/LeadTypes/00000000-0000-4000-a000-000000000001",
"owner": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/Clients/0d000000-0000-4000-a000-000000000001",
"reason_of_close": null,
"step": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/Steps/07000000-0000-4000-a000-000000000001",
"unit": "https://eu-central.api.coevera.com/api/v100/rest/spaces/nv2_northwind_docs/entities/SalesUnits/05000000-0000-4000-a000-000000000001",
"currency_exchange_rates_list_id": null,
"description": "We run 40 trucks and want dashboards for fuel use.",
"is_archived": false,
"label_flag": 0,
"last_ai_buying_center_applied": null,
"lead_source_id": "19000000-0000-4000-a000-000000000001",
"lead_type_id": "00000000-0000-4000-a000-000000000001",
"name": "Contoso Freight: demo request",
"owner_id": "0d000000-0000-4000-a000-000000000001",
"quick_account_email": "",
"quick_account_name": "Contoso Freight",
"quick_account_phone": "",
"quick_contact_name": "Jordan Reyes",
"quick_email": "jordan.reyes+demo@example.com",
"quick_phone": "+1 206 555 0115",
"ranking": 50,
"reason_of_close_description": "",
"reason_of_close_id": null,
"share_mode": 0,
"step_id": "07000000-0000-4000-a000-000000000001",
"table_name": "lead",
"unit_id": "05000000-0000-4000-a000-000000000001",
"revision": 1259,
"primary_contact": null,
"primary_account": null,
"contact_relations": [],
"account_relations": [],
"status": 1,
"sharing_units": [],
"sharing_clients": [],
"documents": [],
"tags": [],
"static_profiles": [],
"qualify_date": null,
"days_in_step": 0,
"is_favorite": false,
"lost_date": null,
"formatted_name": "Contoso Freight: demo request",
"scoring": null,
"modified_by_user": null
}
}Show all 57 linesShow less
{
"data": {
"createLead": {
"lead": {
"id": "08000000-0000-4000-a000-000000000001"
}
}
}
}4. Read it back
Read the lead with its lead source expanded. The + in the visitor's e-mail is kept: the
programs send it in a JSON body, where it needs no encoding.
curl -sS --fail-with-body -G "$COEVERA_BASE/entities/Leads" \
-d "filter[id]=$LEAD_ID" -d expand=lead_source \
-d load-only=id,name,owner_id,status,quick_contact_name,quick_email,quick_account_name,quick_phone,description,lead_source.option_name \
-u "$COEVERA_API_KEY:$COEVERA_API_SECRET"const readBack = await coevera(
`/entities/Leads?${new URLSearchParams({
"filter[id]": leadId,
expand: "lead_source",
"load-only": "id,name,owner_id,status,quick_contact_name,quick_email,quick_account_name,quick_phone,description,lead_source.option_name",
})}`,
);
console.log(JSON.stringify(readBack.data[0], null, 2));read_back = coevera(
"GET",
"/entities/Leads",
params={
"filter[id]": lead_id,
"expand": "lead_source",
"load-only": "id,name,owner_id,status,quick_contact_name,quick_email,quick_account_name,quick_phone,"
"description,lead_source.option_name",
},
)
print(json.dumps(read_back["data"][0], indent=2))jq -n --arg id "$LEAD_ID" '{
query: "query ReadBack($id: ID!) { entities { lead { getById(entityId: $id) { id name status owner { name } quickContactName quickEmail quickAccountName quickPhone description leadSource { optionName } } } } }",
variables: { id: $id }
}' | gqlconst readBack = await gql(
`query ReadBack($id: ID!) {
entities {
lead {
getById(entityId: $id) {
id name status
owner { name }
quickContactName quickEmail quickAccountName quickPhone description
leadSource { optionName }
}
}
}
}`,
{ id: leadId },
);
console.log(JSON.stringify(readBack.entities.lead.getById, null, 2));read_back = gql(
"""
query ReadBack($id: ID!) {
entities {
lead {
getById(entityId: $id) {
id name status
owner { name }
quickContactName quickEmail quickAccountName quickPhone description
leadSource { optionName }
}
}
}
}
""",
{"id": lead_id},
)
print(json.dumps(read_back["entities"]["lead"]["getById"], indent=2)){
"success": true,
"total": 1,
"page_info": {
"start_cursor": "WyIwODAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==",
"end_cursor": "WyIwODAwMDAwMC0wMDAwLTQwMDAtYTAwMC0wMDAwMDAwMDAwMDEiXQ==",
"has_previous_page": false,
"has_next_page": false
},
"data": [
{
"id": "08000000-0000-4000-a000-000000000001",
"lead_source": {
"option_name": "Direct traffic"
},
"description": "We run 40 trucks and want dashboards for fuel use.",
"name": "Contoso Freight: demo request",
"owner_id": "0d000000-0000-4000-a000-000000000001",
"quick_account_name": "Contoso Freight",
"quick_contact_name": "Jordan Reyes",
"quick_email": "jordan.reyes+demo@example.com",
"quick_phone": "+1 206 555 0115",
"status": 1
}
]
}Show all 26 linesShow less
{
"data": {
"entities": {
"lead": {
"getById": {
"id": "08000000-0000-4000-a000-000000000001",
"name": "Contoso Freight: demo request",
"status": "Open",
"owner": {
"name": "Dana Whitfield"
},
"quickContactName": "Jordan Reyes",
"quickEmail": "jordan.reyes+demo@example.com",
"quickAccountName": "Contoso Freight",
"quickPhone": "+1 206 555 0115",
"description": "We run 40 trucks and want dashboards for fuel use.",
"leadSource": {
"optionName": "Direct traffic"
}
}
}
}
}
}What to check
- Step 2 returns one option. An empty
datameans no option has that name. - Step 3 answers 201 (GraphQL: 200) with the lead's
id. - Step 4 returns the lead open (
status: 1, GraphQLOpen), owned by the rep, with the quick fields, the message and the lead source. - Each submission makes a new lead, so a visitor who sends the form twice gets two. To merge
repeats, look for an open lead first with
filter[quick_email](GraphQLquickEmail: { eq: [...] }), and URL-encode the address: a raw+in a query string arrives as a space and matches nothing.
Clean up
The program leaves the lead in your space. Delete it with POST /entities/Leads/batch-delete.
Limits
- The program trusts the submission. Validate it, and protect the form from spam, before you call the API.
- Coevera also has its own online forms, which create leads without a server of yours; this recipe does not cover them.
- A lead becomes an opportunity only through GraphQL
qualifyLead; see Lead lifecycle.