Add campaign step
This endpoint allows you to add a new step at the end of a campaign, as long as the parent step has not yet processed any prospects.
Only campaigns with a status of DRAFT or EDITED can be updated. To change the campaign status to EDITED use the /make_editable endpoint.
Request
Endpoint
POST https://api.woodpecker.co/rest/v2/campaigns/{campaign_id}/steps
Headers
x-api-key: {YOUR_API_KEY}
Content-type: application/json
For details on how to authenticate your requests, please see the authentication guide.
Body
The campaign payload includes several objects, which are described in detail below. In order to fetch the parent_id you can use the GET /campaigns structure endpoint
- Email step
- LinkedIn step
{
"parent_id": "9e8e8ebb-c6e1-4b8f-be37-a5542513dbge",
"step": {
"type": "EMAIL",
"delivery_time": {
"TUESDAY": [{ "from": "09:00", "to": "18:00" }],
"WEDNESDAY": [{ "from": "09:00", "to": "18:00" }],
"THURSDAY": [{ "from": "09:00", "to": "18:00" }]
},
"body": {
"versions": [
{
"subject": null,
"message": "<div>Hi {{FIRST_NAME | \"there\"}},</div><div><br /></div><div>This is an example cold email message. </div><div><br /></div><div>Best wishes, </div><div><a href=\"https://woodpecker.co\">Woodpecker</a> team</div><div><a href=\"{{UNSUBSCRIBE}}\">unsubscribe</a></div>",
"signature": "SENDER",
"track_opens": true
},
{
"subject": "Example subject line - version B",
"message": "<div>{{SPINTAX | \"Hi\" | \"Hello\" | \"Good morning\"}} {{FIRST_NAME}},</div><div><br /></div><div>Yet another example of a cold email message. </div><div><br /></div><div>All the best, </div><div><a href=\"https://woodpecker.co\">Woodpecker</a> team</div><div><a href=\"{{UNSUBSCRIBE}}\">unsubscribe</a></div>",
"signature": "NO_SIGNATURE",
"track_opens": false
}
]
}
}
}
{
"parent_id": "9e8e8ebb-c6e1-4b8f-be37-a5542513dbge",
"step": {
"type": "LINKEDIN",
"body": {
"action_type": "CONNECTION_REQUEST",
"linkedin_account_id": 200002,
"versions": [
{
"message": ""
},
{
"message": "Hello {{FIRST_NAME}}, I'd like to add you to my professional network on LinkedIn"
}
]
}
}
}
Body schema
This section provides the body schema for each object in the campaign payloads. For a more detailed overview, please refer to the campaign schema.
- EMAIL step
- LINKEDIN step
This type defines an email step of a campaign, including its content, versions and delivery times.
| Field | Type | Default | Required | Description |
|---|---|---|---|---|
type | string | - | Yes | Use EMAIL to indicate it is an email step |
delivery_time | object | - | Yes | Time intervals during which emails can be sent. Described in more detail below |
body | object | - | Yes | Email content configuration including A/B test versions. Described in more detail below |
followup_after | object | 1 DAY | No | Object that specifies the time delay before processing a prospect in the next step; if delivery_time allows it. If not provided, a default delay of 1 DAY will be applied |
└─range | string | DAY | No | Time unit: DAY, HOUR, MINUTE |
└─value | integer | 1 | No | Value of the time unit (range: 1 - 9999) |
Delivery time object
The delivery_time object defines the time intervals during which email messages can be sent. The timezone will follow the settings of the timezone and prospect_timezone of the campaign configuration.
Each step must define at least one delivery interval. You can assign up to three intervals per day, but they must not overlap. If a day is omitted from the object, no emails will be sent on that day.
To specify a whole day interval, you can use either "from": "00:00", "to": "00:00" or "from": "00:00", "to": "24:00". The first format is set as the default.
| Field | Type | Default | Required | Description |
|---|---|---|---|---|
MONDAY...SUNDAY | array[object] | - | Yes | Array of time windows for each day. Maximum 3 windows per day. The valid keys are the days of the week: MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY |
└─[].from | string | - | Yes | Start time in "HH:mm" format (24-hour) |
└─[].to | string | - | Yes | End time in "HH:mm" format (24-hour) |
Body object
The body and versions objects define the email content, A/B versions, open tracking, and signature settings. Each of these can be configured individually for each version, and at least one version must be present.
| Field | Type | Default | Required | Description |
|---|---|---|---|---|
body.versions | array[object] | - | Yes | Array of email version objects and their definitions. At least one version is required |
└─[].subject | string/null | - | Yes, for the first EMAIL step | Email subject line. Required for the first EMAIL step in a campaign. If multiple versions exist, all must include a subject. For later EMAIL steps, a null subject sends the message as a follow-up in the same thread. Supports snippets like {{FIRST_NAME}}, snippet fallbacks and spintax |
└─[].message | string | - | Yes | Email body content in HTML format. Supports snippets like {{FIRST_NAME}}, snippet fallbacks and spintax. To track individual link clicks (not recommended), enclose the href attribute value in a {{CLICK}} snippet. Example: <a href=\"{{CLICK=https://google.com}}\">click here</a> |
└─[].signature | string | NO_SIGNATURE | No | Whether to use the sender's email account signature. The available options are: SENDER or NO_SIGNATURE |
└─[].track_opens | boolean | false | No | Whether to track email opens for this email version |
This type defines a linkedin step of a campaign, including its action type, content and versions. You can define the action to perform within the body. Available actions: VISIT_PROFILE, CONNECTION_REQUEST, DIRECT_MESSAGE
| Field | Type | Default | Required | Description |
|---|---|---|---|---|
type | string | - | Yes | Use LINKEDIN to indicate it is a linkedin step |
body | object | - | Yes | LinkedIn acton configuration. Described in more detail below |
followup_after | object | 1 DAY | No | Object that specifies the time delay before processing a prospect in the next step. If not provided, a default delay of 1 DAY will be applied |
└─range | string | DAY | No | Time unit: DAY, HOUR, MINUTE |
└─value | integer | 1 | No | Value of the time unit (range: 1 - 9999) |
Body object
The body and versions define the LinkedIn action type and its content. Supported actions are: VISIT_PROFILE, CONNECTION_REQUEST, and DIRECT_MESSAGE.
- Profile visit
- Connection request
- Direct message
| Field | Type | Default | Required | Description |
|---|---|---|---|---|
body.linkedin_account_id | integer | - | Yes | Unique ID of a LinkedIn account in Woodpecker that will perform the action. Use /linkedin_accounts endpoint to review it |
body.action_type | string | - | Yes | Action type that will be performed in LinkedIn: VISIT_PROFILE |
| Field | Type | Default | Required | Description |
|---|---|---|---|---|
body.versions | array[object] | - | Yes | Array of LinkedIn action version objects. At least one version is required |
└─[].message | string | null | No | Connection request message content. An empty string ("") and null sends a connection request without a message; otherwise, the provided note will be included with the request. Character limits: CLASSIC accounts — 200 characters, SALES_NAVIGATOR — 300 characters. Supports snippets like {{FIRST_NAME}}, snippet fallbacks and spintax |
body.linkedin_account_id | integer | - | Yes | Unique ID of a LinkedIn account in Woodpecker that will perform the action. Use /linkedin_accounts endpoint to review it |
body.action_type | string | - | Yes | Action type that will be performed in LinkedIn: CONNECTION_REQUEST |
| Field | Type | Default | Required | Description |
|---|---|---|---|---|
body.versions | array[object] | - | Yes | Array of LinkedIn action version objects. At least one version is required |
└─[].message | string | - | Yes | Direct message content. Unlike other actions, message content is required. Character limit: 6000 characters. Supports snippets like {{FIRST_NAME}}, snippet fallbacks and spintax |
body.linkedin_account_id | integer | - | Yes | Unique ID of a LinkedIn account in Woodpecker that will perform the action. Use /linkedin_accounts endpoint to review it |
body.action_type | string | - | Yes | Action type that will be performed in LinkedIn: DIRECT_MESSAGE |
Request samples
Add step
- cURL
- Python
- Java
- Node.js
- PHP
curl --request POST \
--url "https://api.woodpecker.co/rest/v2/campaigns/{campaign_id}/steps" \
--header "x-api-key: {YOUR_API_KEY}" \
--header "Content-Type: application/json" \
--data '{
"parent_id": "9e8e8ebb-c6e1-4b8f-be37-a5542513dbge",
"step": {
"type": "EMAIL",
"delivery_time": {
"TUESDAY": [
{
"from": "09:00",
"to": "18:00"
}
]
},
"body": {
"versions": [
{
"subject": null,
"message": "<div>Hi {{FIRST_NAME | \"there\"}},</div><div><br /></div><div>This is an example cold email message. </div><div><br /></div><div>Best wishes, </div><div><a href=\"https://woodpecker.co\">Woodpecker</a> team</div><div><a href=\"{{UNSUBSCRIBE}}\">unsubscribe</a></div>",
"signature": "SENDER",
"track_opens": true
}
]
},
"followup_after": {
"range": "HOUR",
"value": 10
}
}
}'
import requests
def createCampaignStep(campaign_id):
url = f"https://api.woodpecker.co/rest/v2/campaigns/{campaign_id}/steps"
headers = {
"x-api-key": "{YOUR_API_KEY}",
"Content-Type": "application/json"
}
payload = {
"parent_id": "9e8e8ebb-c6e1-4b8f-be37-a5542513dbge",
"step": {
"type": "EMAIL",
"delivery_time": {
"TUESDAY": [
{
"from": "09:00",
"to": "18:00"
}
]
},
"body": {
"versions": [
{
"subject": None,
"message": "<div>Hi {{FIRST_NAME | \"there\"}},</div><div><br /></div><div>This is an example cold email message. </div><div><br /></div><div>Best wishes, </div><div><a href=\"https://woodpecker.co\">Woodpecker</a> team</div><div><a href=\"{{UNSUBSCRIBE}}\">unsubscribe</a></div>",
"signature": "SENDER",
"track_opens": True
}
]
},
"followup_after": {
"range": "HOUR",
"value": 10
}
}
}
response = requests.post(url, headers=headers, json=payload)
if response.status_code == 201:
print("POST successful:", response.json())
else:
print("POST failed with status:", response.status_code)
if __name__ == "__main__":
createCampaignStep(123) # Example campaign ID
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.*;
public class WoodpeckerApiClient {
public static void main(String[] args) {
String apiKey = "{YOUR_API_KEY}";
String campaignId = "{campaign_id}";
String apiUrl = "https://api.woodpecker.co/rest/v2/campaigns/" + campaignId + "/steps";
try {
Map<String, Object> body = createRequestBody();
ObjectMapper objectMapper = new ObjectMapper();
String jsonBody = objectMapper.writeValueAsString(body);
int responseCode = sendPostRequest(apiUrl, apiKey, jsonBody);
System.out.println("Response Code: " + responseCode);
} catch (Exception e) {
e.printStackTrace();
}
}
private static Map<String, Object> createRequestBody() {
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("parent_id", "9e8e8ebb-c6e1-4b8f-be37-a5542513dbge");
// Step object
Map<String, Object> step = new HashMap<>();
step.put("type", "EMAIL");
// Delivery time
Map<String, List<Map<String, String>>> deliveryTime = new HashMap<>();
deliveryTime.put("TUESDAY", Collections.singletonList(Map.of("from", "09:00", "to", "18:00")));
step.put("delivery_time", deliveryTime);
// Email body
Map<String, Object> emailBody = new HashMap<>();
Map<String, Object> version = new HashMap<>();
version.put("subject", null);
version.put("message", "<div>Hi {{FIRST_NAME | \"there\"}},</div><div><br /></div><div>This is an example cold email message. </div><div><br /></div><div>Best wishes, </div><div><a href=\"https://woodpecker.co\">Woodpecker</a> team</div><div><a href=\"{{UNSUBSCRIBE}}\">unsubscribe</a></div>");
version.put("signature", "SENDER");
version.put("track_opens", true);
emailBody.put("versions", Collections.singletonList(version));
step.put("body", emailBody);
// Follow-up after
Map<String, Object> followupAfter = new HashMap<>();
followupAfter.put("range", "HOUR");
followupAfter.put("value", 10);
step.put("followup_after", followupAfter);
requestBody.put("step", step);
return requestBody;
}
private static int sendPostRequest(String apiUrl, String apiKey, String jsonBody) throws Exception {
URL url = new URL(apiUrl);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("POST");
connection.setRequestProperty("x-api-key", apiKey);
connection.setRequestProperty("Content-Type", "application/json");
connection.setDoOutput(true);
try (OutputStream os = connection.getOutputStream()) {
byte[] input = jsonBody.getBytes("utf-8");
os.write(input, 0, input.length);
}
return connection.getResponseCode();
}
}
const axios = require("axios");
async function createCampaignStep(campaignId) {
const url = `https://api.woodpecker.co/rest/v2/campaigns/${campaignId}/steps`;
const headers = {
"x-api-key": "{YOUR_API_KEY}",
"Content-Type": "application/json"
};
const data = {
parent_id: "9e8e8ebb-c6e1-4b8f-be37-a5542513dbge",
step: {
type: "EMAIL",
delivery_time: {
TUESDAY: [
{
from: "09:00",
to: "18:00"
}
]
},
body: {
versions: [
{
subject: null,
message: "<div>Hi {{FIRST_NAME | \"there\"}},</div><div><br /></div><div>This is an example cold email message. </div><div><br /></div><div>Best wishes, </div><div><a href=\"https://woodpecker.co\">Woodpecker</a> team</div><div><a href=\"{{UNSUBSCRIBE}}\">unsubscribe</a></div>",
signature: "SENDER",
track_opens: true
}
]
},
followup_after: {
range: "HOUR",
value: 10
}
}
};
try {
const response = await axios.post(url, data, { headers: headers });
if (response.status === 201) {
console.log("POST successful:", response.data);
} else {
console.error("POST failed with status:", response.status);
}
} catch (error) {
console.error("Request error:", error.response?.status || error.message);
}
}
createCampaignStep(123); // Example campaign ID
<?php
require 'vendor/autoload.php';
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
$client = new Client([
'base_uri' => 'https://api.woodpecker.co/rest/v2/',
'headers' => [
'x-api-key' => getenv('WOODPECKER_API_KEY'),
'Content-Type' => 'application/json',
],
]);
$campaignId = '{campaign_id}';
try {
$response = $client->post("campaigns/{$campaignId}/steps", [
'json' => [
'parent_id' => '9e8e8ebb-c6e1-4b8f-be37-a5542513dbge',
'step' => [
'type' => 'EMAIL',
'delivery_time' => [
'TUESDAY' => [
['from' => '09:00', 'to' => '18:00']
]
],
'body' => [
'versions' => [
[
'subject' => null,
'message' => '<div>Hi {{FIRST_NAME | "there"}},</div><div><br /></div><div>This is an example cold email message. </div><div><br /></div><div>Best wishes, </div><div><a href="https://woodpecker.co">Woodpecker</a> team</div><div><a href="{{UNSUBSCRIBE}}">unsubscribe</a></div>',
'signature' => 'SENDER',
'track_opens' => true
]
]
],
'followup_after' => [
'range' => 'HOUR',
'value' => 10
]
]
],
]);
echo $response->getStatusCode(), "\n";
echo $response->getBody(), "\n";
} catch (RequestException $e) {
echo "Error: ", $e->getMessage(), "\n";
if ($e->hasResponse()) {
echo $e->getResponse()->getBody(), "\n";
}
}
Response
Response examples
- 201
- 400
- 401
- 404
- 409
- 500
The step has been added. A full campaign payload will be returned.
Invalid request or malformed syntax. Please review the request
{
"code": "INPUT_DATA_VALIDATION_FAILURE",
"message": "Input data validation failure",
"details": {
"errors": [
{
"field": "Field name",
"detail": "Issue description"
}
]
}
}
Body schema
| Field | Type | Description |
|---|---|---|
code | string | Error code |
message | string | Error message |
details | object/null | Additional information if available |
└─errors | array[object] | An array of error objects |
└─[].field | string | Specifies the parameter with an issue |
└─[].detail | string | Description of the issue |
An issue with authorization. Please review the authorization guide.
{
"title": "Unauthorized",
"status": 401,
"detail": "Invalid api key",
"timestamp": "2025-03-05 17:57:00"
}
Body schema
| Field | Type | Description |
|---|---|---|
title | string | A short title describing the error |
status | integer | The HTTP status code |
detail | string | A detailed message explaining the error |
timestamp | string | The timestamp when the error occurred, YYYY-MM-DD HH:MM:SS UTC |
The requested campaign doesn't exist.
{
"code": "CAMPAIGN_NOT_EXIST",
"message": "Campaign not found",
"details": null
}
Body schema
| Field | Type | Description |
|---|---|---|
code | string | Error code |
message | string | Error message |
details | string/null | Additional information |
The parent has either already processed some prospects, is not the final step of the campaign, or the campaign is in a status that prevents edits.
{
"code": "PARENT_BRANCH_NOT_LINKABLE" | "NOT_EDITABLE_STATUS" | "API_UNSUPPORTED_CAMPAIGN_FEATURES",
"message": "Can not link new steps to this parent" | "The campaign must be in DRAFT or EDITED status to be updated" | "Campaign contains currently unsupported features",
"details": null
}
Body schema
| Field | Type | Description |
|---|---|---|
code | string | Error code |
message | string | Error message |
details | string/null | Additional information |
Unexpected error, please try again later.
{
"code": "UNKNOWN",
"message": "Unknown error during add step call",
"details": null
}
Body schema
| Field | Type | Description |
|---|---|---|
code | string | Error code |
message | string | Error message |
details | string/null | Additional information |