> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.heyreach.io/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.heyreach.io/_mcp/server.

# UpdateSequenceV2

POST https://api//api/public/campaign/UpdateSequenceV2
Content-Type: application/json

## Creates or fully replaces the sequence (workflow) of a campaign, voice steps included.

The v2 version of **UpdateSequence**, and the write to use for any campaign whose sequence has voice notes. Two things differ from v1:

1. A `MESSAGE` node's payload carries `messageVersions` instead of `messages`, so you can read, write and delete voice versions, not only text.

2. The `messageVersions` list you send **replaces** what is stored: any version you leave out is deleted, voice versions included. Read the sequence with **GetCampaignSequenceV2** first and send back what you want to keep. Deleting a version never deletes the recording behind it.

The START node is implicit; the root of the request must be your first action node. Every path through the tree must end with an `END` node.

Allowed statuses: DRAFT, SCHEDULED, PAUSED. Any other status returns 400 `CAMPAIGN_STATUS_NOT_EDITABLE`. Once leads have entered the campaign, the shape of the sequence must stay the same - only payloads and delays can change - and a request that changes it returns 400 `SEQUENCE_UPDATE_INCONSISTENT`.

### Body Parameters

* **campaignId** *(long, required)*: The campaign to update.

* **sequence** *(PublicSequenceNodeDtoV2, required)*: Root node of the workflow tree.

### PublicSequenceNodeDtoV2 fields

The same as the v1 node (see **UpdateSequence**), except for the `MESSAGE` payload.

* **nodeType** *(string, required)*: One of `CONNECTION_REQUEST`, `MESSAGE`, `INMAIL`, `VIEW_PROFILE`, `FOLLOW`, `LIKE_POST`, `FIND_EMAIL`, `CHECK_IS_CONNECTION`, `CHECK_IS_OPEN_PROFILE`, `SEND_LEAD_TO_INSTANTLY`, `SEND_LEAD_TO_SMARTLEAD`, `SEND_LEAD_TO_BISON`, `END`. A node without a `nodeType` is rejected with 400 `FIELD_VALUE_INVALID`.

* **unconditionalNode** *(PublicSequenceNodeDtoV2, required for non-END nodes)*: The next node to follow unconditionally, or when a branching condition is `false`.

* **conditionalNode** *(PublicSequenceNodeDtoV2)*: The next node when the branching condition is `true`. Required for `CONNECTION_REQUEST`, `CHECK_IS_CONNECTION`, `CHECK_IS_OPEN_PROFILE` and `FIND_EMAIL`. On `MESSAGE` and `INMAIL` it is the exit taken when the lead replies and must be an `END` node; if you leave it out, an `END` node is added for you. Other nodes must not set it.

* **actionDelay** *(int, optional)* / **actionDelayUnit** *(string, optional)*: The wait before this node runs. Range 0-100; unit `HOUR` (the default) or `DAY`. An omitted `actionDelay` is `0`. The unconditional child of `CONNECTION_REQUEST`, `MESSAGE`, `INMAIL`, `VIEW_PROFILE`, `FOLLOW` and `LIKE_POST`, and the conditional child of `CONNECTION_REQUEST`, need a delay of at least 3 hours and at most 500 days - `END` nodes included - or the request is rejected with 400 `SEQUENCE_NODE_DELAY_INVALID`. `actionDelay: 3` with `actionDelayUnit: "HOUR"` is always valid.

* **payload** *(object, conditional)*: Node-specific. Required for `CONNECTION_REQUEST`, `MESSAGE`, `INMAIL`, `LIKE_POST`, `SEND_LEAD_TO_INSTANTLY`, `SEND_LEAD_TO_SMARTLEAD` and `SEND_LEAD_TO_BISON`; omitting it is rejected with 400 `SEQUENCE_NODE_PAYLOAD_MISSING`. Every payload except `MESSAGE` is the same as in v1 - see **UpdateSequence**.

* **externalReference** *(string, optional)*: Your own tracking ID for this step. Max 100 characters.

### MESSAGE payload

* **messageVersions** *(PublicMessageVersion\[], required)*: One entry per version of the step; one version is picked at random for each lead. At least one is required. The list replaces what is stored.

* **fallbackMessage** *(string)*: Plain text sent instead of a version whose text uses a variable the lead has no value for, and instead of a voice version whose voice note cannot be produced. **Required when any version is VOICE**, and when a TEXT or DYNAMIC version uses a variable other than `{FIRST_NAME}`, `{LAST_NAME}`, `{MY_FIRST_NAME}` and `{MY_LAST_NAME}`. It may itself use only those four variables. Max 8000 characters.

### PublicMessageVersion fields

* **type** *(string)*: `TEXT` (the default) or `VOICE`.

* **voiceMode** *(string)*: Used only when `type` is `VOICE`. `DYNAMIC` (the default) speaks `message` as an AI voice note in each sender's assigned voice; `STATIC` sends one uploaded recording (`staticClipId`) as it is.

* **message** *(string)*: For `TEXT`, the message text, max 8000 characters. For a DYNAMIC voice version, the script that is spoken: max 800 characters and at least 3 spoken words (a `{VARIABLE}` counts as one word). Must be empty on a STATIC version.

* **staticClipId** *(long)*: STATIC versions only, and required there: the `clipId` returned by **UploadStaticClip**. It must be a recording in your workspace.

* **messageId** *(string, optional)*: The version's id. **When you edit a sequence, send back the id GetCampaignSequenceV2 returned**: a voice note being generated for a lead is tied to its version's id, and a version sent without an id gets a new one. Leave it out only for a new version. Ids must be unique within a step, and `FALLBACK`, `MANUAL` and `unknown` (in any letter case) are reserved.

* **durationMs** *(int, read-only)*: Length of the version's recording in milliseconds. Ignored on write.

* **transcript** *(string, read-only)*: Transcript of the version's recording. `null` is normal and never blocks sending. Ignored on write.

* **staticClipTranscript** *(string)*: Read-only on a STATIC version: the server always reports the recording's own transcript and ignores any value you send. Must be empty on a TEXT or DYNAMIC version.

* **importedTranscript** *(string)*: Read-only on a STATIC version: the script a shared step arrived with, shown only while the step has no recording, and ignored on write. Must be empty on a TEXT or DYNAMIC version.

* **needsRecording** *(bool, read-only)*: `true` on a STATIC version that has no recording. Ignored on write.

### Sending back what GetCampaignSequenceV2 returned

A body read from **GetCampaignSequenceV2** can be sent back unchanged: `durationMs`, `transcript` and `needsRecording` are ignored, and on a STATIC version so are `staticClipTranscript` and `importedTranscript`, which the server sets from the version's recording. Sending them back never causes a 400. Two cases still need a change first:

* A STATIC version that needs a recording (`needsRecording: true`) is rejected with 400 `SEQUENCE_NODE_PAYLOAD_INVALID`:

```json
{
  "errorMessage": "Error in one of the nodes in the campaign sequence. A pre-recorded voice clip in a \"Send Message\" action has no recording attached. Upload the clip and use the id the upload returns.",
  "errorCode": "SEQUENCE_NODE_PAYLOAD_INVALID"
}

```

Give it a `staticClipId`, or replace it with a DYNAMIC version whose `message` is the script, or remove it (the step must keep at least one version).

* A version whose `staticClipId` is not a recording in your workspace is rejected with 400 `STATIC_CLIP_NOT_FOUND`: `Error in one of the nodes in the campaign sequence. A pre-recorded voice clip in a "Send Message" action refers to a recording that does not exist in this workspace. Upload the clip again in this workspace.`

### Voice rules that return 400

Nothing is saved when any of these is broken:

* `messages` on a `MESSAGE` payload (`FIELD_NOT_SUPPORTED`). It is the v1 field, and even `"messages": []` is refused: `'messages' is not supported on the v2 endpoints. Use 'messageVersions', or call GetCampaignSequence, UpdateSequence or Create for the text-only v1 contract.` `messages` stays valid on `CONNECTION_REQUEST` and `INMAIL` payloads.

* `staticClipId`, or a non-empty `staticClipTranscript` or `importedTranscript`, on a TEXT or DYNAMIC version (`FIELD_VALUE_INVALID`). The message names the field, for example: `'staticClipTranscript' only applies to a STATIC voice version (type VOICE, voiceMode STATIC); remove it from this version.`

* A non-empty `message` on a STATIC version (`FIELD_VALUE_INVALID`): `'message' must be empty on a STATIC voice version: what the lead hears, and its transcript, come from the recording (staticClipId). Use a DYNAMIC voice version to have a script spoken, or a TEXT version to send text.`

* A reserved `messageId`, or the same `messageId` twice in one step (`FIELD_VALUE_INVALID`).

* A DYNAMIC script with fewer than 3 spoken words (`VOICE_SCRIPT_TOO_SHORT`), for example: `The AI voice script at sequence.conditionalNode.payload.messageVersions[1] has 2 spoken word(s). AI voice scripts need at least 3 words — very short scripts come out garbled.` A short script already stored on the campaign and sent back unchanged with its `messageId` is accepted.

* A DYNAMIC script over 800 characters, an empty TEXT or DYNAMIC `message`, a voice step without a valid `fallbackMessage`, or a step without versions (`SEQUENCE_NODE_PAYLOAD_INVALID`).

### Errors

Every `{ "errorMessage": "..." }` error body carries an `errorCode`; branch on the code, not on the message. Request-validation 400s (for example `actionDelay` outside 0-100, or a `nodeType` name that does not exist) use the standard problem-details shape instead.

* **400** `CAMPAIGN_STATUS_NOT_EDITABLE`, `SEQUENCE_UPDATE_INCONSISTENT`, `SEQUENCE_INVALID` (the tree's structure is not valid), `SEQUENCE_NODE_PAYLOAD_MISSING`, `SEQUENCE_NODE_PAYLOAD_INVALID`, `SEQUENCE_NODE_DELAY_INVALID`, `FIELD_NOT_SUPPORTED`, `FIELD_VALUE_INVALID`, `REQUIRED_FIELD_MISSING` (a `SEND_LEAD_TO_*` payload is missing a required field), `VOICE_SCRIPT_TOO_SHORT`, `STATIC_CLIP_NOT_FOUND`, and for `SEND_LEAD_TO_*` nodes `INTEGRATION_NOT_CONFIGURED`, `INTEGRATION_CREDENTIALS_INVALID` and `INTEGRATION_REQUEST_FAILED`.
* **403** `NO_ACTIVE_SUBSCRIPTION`: the workspace has no active subscription.
* **404** `CAMPAIGN_NOT_FOUND`, `CAMPAIGN_API_NOT_ENABLED` (the campaign API is not enabled for your account), and `INTEGRATION_RESOURCE_NOT_FOUND` (the Instantly, Smartlead or EmailBison campaign or list of a `SEND_LEAD_TO_*` node was not found).

Reference: https://docs.heyreach.io/hey-reach-api/campaigns/update-sequence-v-2

## Request

### Headers

- `X-API-KEY` (string, optional) — API key header using this scheme. Example: "X-API-KEY: \{API\_KEY}"

### Body (application/json)

This endpoint expects an object.

- `campaignId` (long, required) — The campaign to update.
- `sequence` (PublicSequenceNodeDtoV2, required) — One node of a campaign sequence on the v2 endpoints (**GetCampaignSequenceV2**, **UpdateSequenceV2**, **CreateV2**). Nodes form a tree through `conditionalNode` and `unconditionalNode`. The START node is implicit - the root is your first action node - and every path ends with an `END` node.

## Errors

### 400 Update Sequence V2campaigns Request Bad Request Error

Bad Request. See **Errors** above for what each `errorCode` means.

- `errorMessage` (string, required)
- `errorCode` (enum, optional)
  - Allowed values: `PAGING_OUT_OF_RANGE`

### 403 Update Sequence V2campaigns Request Forbidden Error

Forbidden. `NO_ACTIVE_SUBSCRIPTION`: the workspace has no active subscription.

- `errorMessage` (string, required)
- `errorCode` (enum, optional)
  - Allowed values: `NO_ACTIVE_SUBSCRIPTION`, `VOICE_NOTES_NOT_ENABLED`, `ELEVENLABS_NOT_ENABLED`

### 404 Update Sequence V2campaigns Request Not Found Error

Not Found. `CAMPAIGN_NOT_FOUND`: no campaign with this id in the workspace. `CAMPAIGN_API_NOT_ENABLED`: the campaign API is not enabled for your account. `INTEGRATION_RESOURCE_NOT_FOUND`: the Instantly, Smartlead or EmailBison campaign or list of a `SEND_LEAD_TO_*` node was not found.

- `errorMessage` (string, required)
- `errorCode` (enum, optional)
  - Allowed values: `ELEVENLABS_VOICE_NOT_FOUND`, `ELEVENLABS_VOICE_ACCESS_DENIED`

## Types

### PublicSequenceNodeDtoV2

One node of a campaign sequence on the v2 endpoints (**GetCampaignSequenceV2**, **UpdateSequenceV2**, **CreateV2**). Nodes form a tree through `conditionalNode` and `unconditionalNode`. The START node is implicit - the root is your first action node - and every path ends with an `END` node.

- `nodeType` (enum, required) — The action or check this node performs. Required on every node.
  - Allowed values: `CONNECTION_REQUEST`, `MESSAGE`, `INMAIL`, `VIEW_PROFILE`, `FOLLOW`, `LIKE_POST`, `END`, `CHECK_IS_CONNECTION`, `CHECK_IS_OPEN_PROFILE`, `SEND_LEAD_TO_INSTANTLY`, `FIND_EMAIL`, `SEND_LEAD_TO_SMARTLEAD`, `SEND_LEAD_TO_BISON`
- `actionDelay` (integer, optional, default: 0) — The wait before this node runs, in `actionDelayUnit`. A node after an action step needs at least 3 hours - see **UpdateSequenceV2**.
- `actionDelayUnit` (enum, optional, default: HOUR) — Unit of `actionDelay`.
  - Allowed values: `HOUR`, `DAY`
- `payload` (PublicSequenceNodeDtoV2Payload, optional) — Node-specific configuration. A `MESSAGE` node uses the v2 message payload; every other node type uses its v1 payload (see **UpdateSequence**). `VIEW_PROFILE`, `FOLLOW`, `FIND_EMAIL`, `CHECK_IS_CONNECTION`, `CHECK_IS_OPEN_PROFILE` and `END` have no payload.
- `conditionalNode` (PublicSequenceNodeDtoV2, optional) — The next node when the branching condition is `true`. On `MESSAGE` and `INMAIL`, the `END` node taken when the lead replies.
- `unconditionalNode` (PublicSequenceNodeDtoV2, optional) — The next node to follow unconditionally, or when a branching condition is `false`. Required for every node except `END`.
- `externalReference` (string, optional, nullable) — Your own tracking ID for this step. Left out of responses when not set.

### PublicSequenceNodeDtoV2Payload

Node-specific configuration. A `MESSAGE` node uses the v2 message payload; every other node type uses its v1 payload (see **UpdateSequence**). `VIEW_PROFILE`, `FOLLOW`, `FIND_EMAIL`, `CHECK_IS_CONNECTION`, `CHECK_IS_OPEN_PROFILE` and `END` have no payload.

- Public Message Payload V2: PublicMessagePayloadV2
- map from string to any

### PublicMessagePayloadV2

The payload of a `MESSAGE` node on the v2 endpoints.

- `messageVersions` (list of PublicMessageVersion, required) — One entry per version of the step; one version is picked at random for each lead. At least one is required. On write the list replaces what is stored: versions you leave out are deleted.
- `fallbackMessage` (string, optional, nullable) — Plain text sent instead of a version whose text uses a variable the lead has no value for, and instead of a voice version whose voice note cannot be produced. Required when any version is VOICE. May use only `{FIRST_NAME}`, `{LAST_NAME}`, `{MY_FIRST_NAME}` and `{MY_LAST_NAME}`. Max 8000 characters.

### PublicMessageVersion

One version of a `MESSAGE` step: a text message, an AI voice note (VOICE + DYNAMIC) or an uploaded recording (VOICE + STATIC).

- `message` (string, optional, nullable) — TEXT: the message text, max 8000 characters. DYNAMIC: the spoken script, max 800 characters and at least 3 spoken words. STATIC: must be empty.
- `type` (enum, optional, default: TEXT)
  - Allowed values: `TEXT`, `VOICE`
- `voiceMode` (enum, optional, default: DYNAMIC) — Used only when `type` is `VOICE`.
  - Allowed values: `DYNAMIC`, `STATIC`
- `messageId` (string, optional, nullable) — The version's id. Send back the id GetCampaignSequenceV2 returned when you edit a sequence; leave it out only for a new version. Unique within a step; `FALLBACK`, `MANUAL` and `unknown` are reserved.
- `staticClipId` (long, optional, nullable) — STATIC only, and required there: the `clipId` returned by **UploadStaticClip**. Refused on TEXT and DYNAMIC versions.
- `durationMs` (integer, optional, nullable) — Length of the version's recording in milliseconds. Ignored on write.
- `transcript` (string, optional, nullable) — Transcript of the version's recording. Null is normal and never blocks sending. Ignored on write.
- `staticClipTranscript` (string, optional, nullable) — Read-only on a STATIC version: always the recording's own transcript, and a value you send is ignored. Must be empty on TEXT and DYNAMIC versions.
- `importedTranscript` (string, optional, nullable) — Read-only on a STATIC version: the script a shared step arrived with, shown only while it has no recording, and ignored on write. Must be empty on TEXT and DYNAMIC versions.
- `needsRecording` (boolean, optional) — True on a STATIC version that has no recording. Give such a version a `staticClipId` before you send it back, or the write is rejected with 400. The value you send is ignored.

## Examples

**Request**

```json
{
  "campaignId": 12345,
  "sequence": {
    "nodeType": "CONNECTION_REQUEST",
    "actionDelay": 0,
    "actionDelayUnit": "DAY",
    "payload": {
      "fallbackMessage": "Hi there, I'd love to connect!",
      "messageVersions": [
        {}
      ]
    }
  }
}
```

**SDK Code**

```python
import requests

url = "https://api//api/public/campaign/UpdateSequenceV2"

payload = {
    "campaignId": 12345,
    "sequence": {
        "nodeType": "CONNECTION_REQUEST",
        "actionDelay": 0,
        "actionDelayUnit": "DAY",
        "payload": {
            "fallbackMessage": "Hi there, I'd love to connect!",
            "messageVersions": [{}]
        }
    }
}
headers = {
    "X-API-KEY": "{{HR_API_KEY}}",
    "Content-Type": "application/json"
}

response = requests.post(url, json=payload, headers=headers)

print(response.json())
```

```javascript
const url = 'https://api//api/public/campaign/UpdateSequenceV2';
const options = {
  method: 'POST',
  headers: {'X-API-KEY': '{{HR_API_KEY}}', 'Content-Type': 'application/json'},
  body: '{"campaignId":12345,"sequence":{"nodeType":"CONNECTION_REQUEST","actionDelay":0,"actionDelayUnit":"DAY","payload":{"fallbackMessage":"Hi there, I\'d love to connect!","messageVersions":[{}]}}}'
};

try {
  const response = await fetch(url, options);
  const data = await response.json();
  console.log(data);
} catch (error) {
  console.error(error);
}
```

```go
package main

import (
	"fmt"
	"strings"
	"net/http"
	"io"
)

func main() {

	url := "https://api//api/public/campaign/UpdateSequenceV2"

	payload := strings.NewReader("{\n  \"campaignId\": 12345,\n  \"sequence\": {\n    \"nodeType\": \"CONNECTION_REQUEST\",\n    \"actionDelay\": 0,\n    \"actionDelayUnit\": \"DAY\",\n    \"payload\": {\n      \"fallbackMessage\": \"Hi there, I'd love to connect!\",\n      \"messageVersions\": [\n        {}\n      ]\n    }\n  }\n}")

	req, _ := http.NewRequest("POST", url, payload)

	req.Header.Add("X-API-KEY", "{{HR_API_KEY}}")
	req.Header.Add("Content-Type", "application/json")

	res, _ := http.DefaultClient.Do(req)

	defer res.Body.Close()
	body, _ := io.ReadAll(res.Body)

	fmt.Println(res)
	fmt.Println(string(body))

}
```

```ruby
require 'uri'
require 'net/http'

url = URI("https://api//api/public/campaign/UpdateSequenceV2")

http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true

request = Net::HTTP::Post.new(url)
request["X-API-KEY"] = '{{HR_API_KEY}}'
request["Content-Type"] = 'application/json'
request.body = "{\n  \"campaignId\": 12345,\n  \"sequence\": {\n    \"nodeType\": \"CONNECTION_REQUEST\",\n    \"actionDelay\": 0,\n    \"actionDelayUnit\": \"DAY\",\n    \"payload\": {\n      \"fallbackMessage\": \"Hi there, I'd love to connect!\",\n      \"messageVersions\": [\n        {}\n      ]\n    }\n  }\n}"

response = http.request(request)
puts response.read_body
```

```java
import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.Unirest;

HttpResponse<String> response = Unirest.post("https://api//api/public/campaign/UpdateSequenceV2")
  .header("X-API-KEY", "{{HR_API_KEY}}")
  .header("Content-Type", "application/json")
  .body("{\n  \"campaignId\": 12345,\n  \"sequence\": {\n    \"nodeType\": \"CONNECTION_REQUEST\",\n    \"actionDelay\": 0,\n    \"actionDelayUnit\": \"DAY\",\n    \"payload\": {\n      \"fallbackMessage\": \"Hi there, I'd love to connect!\",\n      \"messageVersions\": [\n        {}\n      ]\n    }\n  }\n}")
  .asString();
```

```php
<?php
require_once('vendor/autoload.php');

$client = new \GuzzleHttp\Client();

$response = $client->request('POST', 'https://api//api/public/campaign/UpdateSequenceV2', [
  'body' => '{
  "campaignId": 12345,
  "sequence": {
    "nodeType": "CONNECTION_REQUEST",
    "actionDelay": 0,
    "actionDelayUnit": "DAY",
    "payload": {
      "fallbackMessage": "Hi there, I\'d love to connect!",
      "messageVersions": [
        {}
      ]
    }
  }
}',
  'headers' => [
    'Content-Type' => 'application/json',
    'X-API-KEY' => '{{HR_API_KEY}}',
  ],
]);

echo $response->getBody();
```

```csharp
using RestSharp;

var client = new RestClient("https://api//api/public/campaign/UpdateSequenceV2");
var request = new RestRequest(Method.POST);
request.AddHeader("X-API-KEY", "{{HR_API_KEY}}");
request.AddHeader("Content-Type", "application/json");
request.AddParameter("application/json", "{\n  \"campaignId\": 12345,\n  \"sequence\": {\n    \"nodeType\": \"CONNECTION_REQUEST\",\n    \"actionDelay\": 0,\n    \"actionDelayUnit\": \"DAY\",\n    \"payload\": {\n      \"fallbackMessage\": \"Hi there, I'd love to connect!\",\n      \"messageVersions\": [\n        {}\n      ]\n    }\n  }\n}", ParameterType.RequestBody);
IRestResponse response = client.Execute(request);
```

```swift
import Foundation

let headers = [
  "X-API-KEY": "{{HR_API_KEY}}",
  "Content-Type": "application/json"
]
let parameters = [
  "campaignId": 12345,
  "sequence": [
    "nodeType": "CONNECTION_REQUEST",
    "actionDelay": 0,
    "actionDelayUnit": "DAY",
    "payload": [
      "fallbackMessage": "Hi there, I'd love to connect!",
      "messageVersions": [[]]
    ]
  ]
] as [String : Any]

let postData = JSONSerialization.data(withJSONObject: parameters, options: [])

let request = NSMutableURLRequest(url: NSURL(string: "https://api//api/public/campaign/UpdateSequenceV2")! as URL,
                                        cachePolicy: .useProtocolCachePolicy,
                                    timeoutInterval: 10.0)
request.httpMethod = "POST"
request.allHTTPHeaderFields = headers
request.httpBody = postData as Data

let session = URLSession.shared
let dataTask = session.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in
  if (error != nil) {
    print(error as Any)
  } else {
    let httpResponse = response as? HTTPURLResponse
    print(httpResponse)
  }
})

dataTask.resume()
```