curl --request POST \
--url https://api.erdo.ai/v1/datasets/{datasetSlug}/leads/{leadRef}/next-actions/handoff \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"decision": "<string>",
"action_id": "<string>",
"decided_by_external_ref": "<string>",
"note": "<string>"
}
'import requests
url = "https://api.erdo.ai/v1/datasets/{datasetSlug}/leads/{leadRef}/next-actions/handoff"
payload = {
"decision": "<string>",
"action_id": "<string>",
"decided_by_external_ref": "<string>",
"note": "<string>"
}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({
decision: '<string>',
action_id: '<string>',
decided_by_external_ref: '<string>',
note: '<string>'
})
};
fetch('https://api.erdo.ai/v1/datasets/{datasetSlug}/leads/{leadRef}/next-actions/handoff', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.erdo.ai/v1/datasets/{datasetSlug}/leads/{leadRef}/next-actions/handoff",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'decision' => '<string>',
'action_id' => '<string>',
'decided_by_external_ref' => '<string>',
'note' => '<string>'
]),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>",
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://api.erdo.ai/v1/datasets/{datasetSlug}/leads/{leadRef}/next-actions/handoff"
payload := strings.NewReader("{\n \"decision\": \"<string>\",\n \"action_id\": \"<string>\",\n \"decided_by_external_ref\": \"<string>\",\n \"note\": \"<string>\"\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Authorization", "Bearer <token>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://api.erdo.ai/v1/datasets/{datasetSlug}/leads/{leadRef}/next-actions/handoff")
.header("Authorization", "Bearer <token>")
.header("Content-Type", "application/json")
.body("{\n \"decision\": \"<string>\",\n \"action_id\": \"<string>\",\n \"decided_by_external_ref\": \"<string>\",\n \"note\": \"<string>\"\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.erdo.ai/v1/datasets/{datasetSlug}/leads/{leadRef}/next-actions/handoff")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <token>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"decision\": \"<string>\",\n \"action_id\": \"<string>\",\n \"decided_by_external_ref\": \"<string>\",\n \"note\": \"<string>\"\n}"
response = http.request(request)
puts response.read_body{
"accompanying_email": {
"body_markdown": "<string>",
"error": "<string>",
"execution_ref": "<string>",
"sent_at": "2023-11-07T05:31:56Z",
"status": "<string>",
"subject": "<string>",
"to": "<string>"
},
"action_input": {},
"action_kind": "<string>",
"approval_request_id": "<string>",
"call_outcome": {
"duration_seconds": 123,
"status": "<string>"
},
"canonical_lead_id": "<string>",
"created_by": "<string>",
"dataset_id": "<string>",
"decided_at": "2023-11-07T05:31:56Z",
"decided_by": "<string>",
"decided_by_external_ref": "<string>",
"due_at": "2023-11-07T05:31:56Z",
"error": "<string>",
"evaluated_at": "2023-11-07T05:31:56Z",
"executed_at": "2023-11-07T05:31:56Z",
"execution_ref": "<string>",
"facts": {
"bedrooms": {
"source_column": "<string>",
"value": "<string>"
},
"broker": {
"source_column": "<string>",
"value": "<string>"
},
"budget": {
"source_column": "<string>",
"value": "<string>"
},
"financing": {
"source_column": "<string>",
"value": "<string>"
},
"language": {
"source_column": "<string>",
"value": "<string>"
},
"purpose": {
"source_column": "<string>",
"value": "<string>"
},
"timeline": {
"source_column": "<string>",
"value": "<string>"
}
},
"handoff_alert": {
"error": "<string>",
"execution_ref": "<string>",
"sent_at": "2023-11-07T05:31:56Z",
"status": "<string>"
},
"id": "<string>",
"lead_reference": "<string>",
"mode": "<string>",
"model": "<string>",
"playbook_revision": 123,
"policy_digest": "<string>",
"priority": "<string>",
"priority_reason": "<string>",
"rationale": "<string>",
"revisit_at": "2023-11-07T05:31:56Z",
"rule": "<string>",
"source": "<string>",
"stage": "<string>",
"status": "<string>",
"undone_by_external_ref": "<string>"
}{
"code": "not_found",
"details": {},
"message": "<string>"
}ResolveLeadHandoffAPI records the sales desk marking a handoff done or not
needed.
A handoff is accepted the moment Erdo files it: it is the desk’s task, with no approval card, and the desk is emailed at once. Nothing automatic ever carries it out, so this is how it is closed — done (the desk dealt with the lead) or not needed. It answers FailedPrecondition when the lead’s decision is not an open handoff, naming what it is instead.
There is deliberately no MCP tool for this, for the same reason as the hold, the close and the undo: it is the desk’s word about a lead it is dealing with, not a model’s. A scoped API key is refused.
curl --request POST \
--url https://api.erdo.ai/v1/datasets/{datasetSlug}/leads/{leadRef}/next-actions/handoff \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"decision": "<string>",
"action_id": "<string>",
"decided_by_external_ref": "<string>",
"note": "<string>"
}
'import requests
url = "https://api.erdo.ai/v1/datasets/{datasetSlug}/leads/{leadRef}/next-actions/handoff"
payload = {
"decision": "<string>",
"action_id": "<string>",
"decided_by_external_ref": "<string>",
"note": "<string>"
}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({
decision: '<string>',
action_id: '<string>',
decided_by_external_ref: '<string>',
note: '<string>'
})
};
fetch('https://api.erdo.ai/v1/datasets/{datasetSlug}/leads/{leadRef}/next-actions/handoff', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.erdo.ai/v1/datasets/{datasetSlug}/leads/{leadRef}/next-actions/handoff",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'decision' => '<string>',
'action_id' => '<string>',
'decided_by_external_ref' => '<string>',
'note' => '<string>'
]),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>",
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://api.erdo.ai/v1/datasets/{datasetSlug}/leads/{leadRef}/next-actions/handoff"
payload := strings.NewReader("{\n \"decision\": \"<string>\",\n \"action_id\": \"<string>\",\n \"decided_by_external_ref\": \"<string>\",\n \"note\": \"<string>\"\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Authorization", "Bearer <token>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://api.erdo.ai/v1/datasets/{datasetSlug}/leads/{leadRef}/next-actions/handoff")
.header("Authorization", "Bearer <token>")
.header("Content-Type", "application/json")
.body("{\n \"decision\": \"<string>\",\n \"action_id\": \"<string>\",\n \"decided_by_external_ref\": \"<string>\",\n \"note\": \"<string>\"\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.erdo.ai/v1/datasets/{datasetSlug}/leads/{leadRef}/next-actions/handoff")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <token>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"decision\": \"<string>\",\n \"action_id\": \"<string>\",\n \"decided_by_external_ref\": \"<string>\",\n \"note\": \"<string>\"\n}"
response = http.request(request)
puts response.read_body{
"accompanying_email": {
"body_markdown": "<string>",
"error": "<string>",
"execution_ref": "<string>",
"sent_at": "2023-11-07T05:31:56Z",
"status": "<string>",
"subject": "<string>",
"to": "<string>"
},
"action_input": {},
"action_kind": "<string>",
"approval_request_id": "<string>",
"call_outcome": {
"duration_seconds": 123,
"status": "<string>"
},
"canonical_lead_id": "<string>",
"created_by": "<string>",
"dataset_id": "<string>",
"decided_at": "2023-11-07T05:31:56Z",
"decided_by": "<string>",
"decided_by_external_ref": "<string>",
"due_at": "2023-11-07T05:31:56Z",
"error": "<string>",
"evaluated_at": "2023-11-07T05:31:56Z",
"executed_at": "2023-11-07T05:31:56Z",
"execution_ref": "<string>",
"facts": {
"bedrooms": {
"source_column": "<string>",
"value": "<string>"
},
"broker": {
"source_column": "<string>",
"value": "<string>"
},
"budget": {
"source_column": "<string>",
"value": "<string>"
},
"financing": {
"source_column": "<string>",
"value": "<string>"
},
"language": {
"source_column": "<string>",
"value": "<string>"
},
"purpose": {
"source_column": "<string>",
"value": "<string>"
},
"timeline": {
"source_column": "<string>",
"value": "<string>"
}
},
"handoff_alert": {
"error": "<string>",
"execution_ref": "<string>",
"sent_at": "2023-11-07T05:31:56Z",
"status": "<string>"
},
"id": "<string>",
"lead_reference": "<string>",
"mode": "<string>",
"model": "<string>",
"playbook_revision": 123,
"policy_digest": "<string>",
"priority": "<string>",
"priority_reason": "<string>",
"rationale": "<string>",
"revisit_at": "2023-11-07T05:31:56Z",
"rule": "<string>",
"source": "<string>",
"stage": "<string>",
"status": "<string>",
"undone_by_external_ref": "<string>"
}{
"code": "not_found",
"details": {},
"message": "<string>"
}Authorizations
An Erdo API key (erdo_api_...) or scoped token (erdo_token_...).
Body
Response
Success response
out beside a gated decision: whether it went, when, in what words, and why it did not if it did not.
It joins the row's own columns (the authoritative state) with the draft stored in action_input, so a caller does not have to read two things and work out which one is true.
Show child attributes
Show child attributes
Show child attributes
Show child attributes
handles for the person who decided this action and the person who took that decision back — opaque strings Erdo stores verbatim and never interprets.
They exist because an application built on `/v1` authenticates a whole team with ONE organization API key, so DecidedBy — the Erdo account that key belongs to — is the same person for every decision anybody there makes. The caller is the only party that can say which of its users acted, so it says, and it resolves these back to names on its own side.
ATTRIBUTION, NEVER AUTHENTICATION. Anything holding the organization's key can write any value here; Erdo does not verify it, must never branch on it, authorize by it, or resolve it against an Erdo identity. It is fine for "Approved by Ana" on a card and must never be presented as proof of who acted. Empty means the caller offered no name, which is every decision made before this existed.
means the lead has not told us.
Show child attributes
Show child attributes
desk a handoff is waiting: whether it went, when, the message to open, and why it did not go if it did not.
The alert is the only MESSAGE a handoff produces. Carrying a handoff out is a person marking it done, which records the literal string "accepted" and sends nothing — so a surface that wants to show what a handoff SENT has to read this and not the row's execution ref, and one that wants to know whether the desk has dealt with the lead reads the row's status. It is also why these facts are exposed at all: a sent alert is a real, openable message, and the timeline used to show a handoff as an unopenable line that mentioned no email whatever.
It reads only the row's own columns, unlike AccompanyingEmailState, because the alert's words are not drafted onto the decision — they are composed at send time from the card by buildHandoffAlert. The message itself is read back from the email service by ExecutionRef, which is the only copy of it there is.
Show child attributes
Show child attributes
is what says whether the decision is stale — not the revision number. The revision bumps on every save, so a decision at revision 9 may have been decided under exactly the rules revision 10 carries; comparing it with LeadPlaybook.PolicyDigest answers the question the revision only hints at. Empty on every decision written before digests existed and on one a person recorded by hand. See policy.go for the two halves it is made of.

