curl --request PATCH \
--url https://api.politicalcomms.com/v1/projects/{id} \
--header 'Content-Type: application/json' \
--header 'X-API-Key: <api-key>' \
--data '
{
"name": "Spring Outreach (revised)",
"message_text": "Hi {first_name}, early voting starts Monday. Learn more: {link}",
"media_ids": []
}
'import requests
url = "https://api.politicalcomms.com/v1/projects/{id}"
payload = {
"name": "Spring Outreach (revised)",
"message_text": "Hi {first_name}, early voting starts Monday. Learn more: {link}",
"media_ids": []
}
headers = {
"X-API-Key": "<api-key>",
"Content-Type": "application/json"
}
response = requests.patch(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'PATCH',
headers: {'X-API-Key': '<api-key>', 'Content-Type': 'application/json'},
body: JSON.stringify({
name: 'Spring Outreach (revised)',
message_text: 'Hi {first_name}, early voting starts Monday. Learn more: {link}',
media_ids: []
})
};
fetch('https://api.politicalcomms.com/v1/projects/{id}', 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.politicalcomms.com/v1/projects/{id}",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "PATCH",
CURLOPT_POSTFIELDS => json_encode([
'name' => 'Spring Outreach (revised)',
'message_text' => 'Hi {first_name}, early voting starts Monday. Learn more: {link}',
'media_ids' => [
]
]),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"X-API-Key: <api-key>"
],
]);
$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.politicalcomms.com/v1/projects/{id}"
payload := strings.NewReader("{\n \"name\": \"Spring Outreach (revised)\",\n \"message_text\": \"Hi {first_name}, early voting starts Monday. Learn more: {link}\",\n \"media_ids\": []\n}")
req, _ := http.NewRequest("PATCH", url, payload)
req.Header.Add("X-API-Key", "<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(string(body))
}HttpResponse<String> response = Unirest.patch("https://api.politicalcomms.com/v1/projects/{id}")
.header("X-API-Key", "<api-key>")
.header("Content-Type", "application/json")
.body("{\n \"name\": \"Spring Outreach (revised)\",\n \"message_text\": \"Hi {first_name}, early voting starts Monday. Learn more: {link}\",\n \"media_ids\": []\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.politicalcomms.com/v1/projects/{id}")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Patch.new(url)
request["X-API-Key"] = '<api-key>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"name\": \"Spring Outreach (revised)\",\n \"message_text\": \"Hi {first_name}, early voting starts Monday. Learn more: {link}\",\n \"media_ids\": []\n}"
response = http.request(request)
puts response.read_body{
"success": true,
"data": {
"project_id": "8a3f5c7e-2d91-4b64-ae08-6f1c9d3e7b52",
"organization_id": "0d9f6a2e-1b34-4c8e-9f21-3e7a8b5c4d10",
"channel": "10dlc",
"brand_id": "7c1de5f0-9a42-4b6d-8e33-2f5a9c7b1e64",
"campaign_id": "4b8fa3c1-6d25-4e9a-bf47-8c2e5d9a3f71",
"toll_free_verification_id": null,
"phone_number_ids": [
"b2e8d4f6-1a37-4c95-8e62-5d9f3b7a1c48"
],
"name": "July GOTV Blast",
"status": "ready",
"type": "broadcast",
"protocol": "sms",
"message_text": "Hi {{first_name}}, election day is coming up!",
"contact_list_ids": [
"3f7a9c1e-8b24-4d6f-a2e5-9c7b3f1a8d42"
],
"suppression_list_ids": [],
"media_ids": [],
"media_urls": [],
"link_tracking_enabled": true,
"link_tracking_destination_url": "https://smithcampaign.com/vote",
"link_tracking_domain_id": "6e2c9a4f-8b51-4d73-a9e6-1f4b8c5d2a90",
"link_tracking_param_field": "phone",
"link_tracking_fallback_url": null,
"opt_out_footer_enabled": true,
"ai_survey_analysis_enabled": false,
"total_recipients": 11800,
"estimated_cost_cents": 35400,
"scheduled_at": null,
"scheduled_timezone": null,
"created_via_api": true,
"created_at": "2025-07-01T14:00:00.000Z",
"updated_at": "2025-07-02T09:15:00.000Z",
"pause_reason": null,
"auto_paused": false,
"daily_cap_bypass": false
}
}Update Project
Update a project while in draft, awaiting_test, ready, or paused. organization_id and type are immutable after creation; including them in the body returns a 400 FIELD_NOT_EDITABLE. All other fields are partial: send only the keys you want to change.
The payload shape follows the project’s type. For surveys, questions is a full replacement: sending it swaps the entire question set (same rules as create). Any edit moves the project to awaiting_test (re-test required), and back to pending approval if it was approved.
Changing the sending setup (broadcast projects). channel, brand_id, campaign_id, and toll_free_verification_id are editable. When any of the four is present, the request must state the full sending setup for the effective channel (the channel you send, else the project’s current channel) plus the numbers: 10dlc requires brand_id and campaign_id (and no toll_free_verification_id); toll-free requires toll_free_verification_id (and no brand_id or campaign_id); short-code takes none of the three. phone_number_ids (or the legacy phone_number_id) is always required with a setup change, because a number belongs to exactly one campaign or toll-free registration. The checks match create: the brand must belong to the project’s organization, the campaign must belong to that brand, and every toll-free number must be linked to the registration (error codes BRAND_NOT_FOUND, BRAND_ACCESS_DENIED, CAMPAIGN_NOT_FOUND, CAMPAIGN_BRAND_MISMATCH, TOLL_FREE_REGISTRATION_NOT_FOUND, TOLL_FREE_REGISTRATION_ACCESS_DENIED, PHONE_NUMBER_REGISTRATION_MISMATCH). A suspended campaign returns 409 CAMPAIGN_SUSPENDED, and an expired campaign returns 409 CAMPAIGN_EXPIRED (register a new campaign to use on the project). If the project’s contact lists, suppression lists, or media belong to the old brand or registration, the request returns 400 ASSET_OUT_OF_SCOPE; send new contact_list_ids, suppression_list_ids, and media_ids in the same request. The brand follows the campaign; switching to toll-free or short-code clears the brand and campaign. Shape errors are validation errors, as on create.
Editing a paused project. To resume after an edit: PATCH the project, then POST /projects/{id}/test (moves it to ready, or awaiting_approval when approval is required), then POST /projects/{id}/schedule. Recipients already sent are never re-sent. Any edit refunds every message the pause was holding, including any still waiting to go out, and the restart sends the edited message and sending setup to everyone not yet sent, billed once.
curl --request PATCH \
--url https://api.politicalcomms.com/v1/projects/{id} \
--header 'Content-Type: application/json' \
--header 'X-API-Key: <api-key>' \
--data '
{
"name": "Spring Outreach (revised)",
"message_text": "Hi {first_name}, early voting starts Monday. Learn more: {link}",
"media_ids": []
}
'import requests
url = "https://api.politicalcomms.com/v1/projects/{id}"
payload = {
"name": "Spring Outreach (revised)",
"message_text": "Hi {first_name}, early voting starts Monday. Learn more: {link}",
"media_ids": []
}
headers = {
"X-API-Key": "<api-key>",
"Content-Type": "application/json"
}
response = requests.patch(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'PATCH',
headers: {'X-API-Key': '<api-key>', 'Content-Type': 'application/json'},
body: JSON.stringify({
name: 'Spring Outreach (revised)',
message_text: 'Hi {first_name}, early voting starts Monday. Learn more: {link}',
media_ids: []
})
};
fetch('https://api.politicalcomms.com/v1/projects/{id}', 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.politicalcomms.com/v1/projects/{id}",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "PATCH",
CURLOPT_POSTFIELDS => json_encode([
'name' => 'Spring Outreach (revised)',
'message_text' => 'Hi {first_name}, early voting starts Monday. Learn more: {link}',
'media_ids' => [
]
]),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"X-API-Key: <api-key>"
],
]);
$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.politicalcomms.com/v1/projects/{id}"
payload := strings.NewReader("{\n \"name\": \"Spring Outreach (revised)\",\n \"message_text\": \"Hi {first_name}, early voting starts Monday. Learn more: {link}\",\n \"media_ids\": []\n}")
req, _ := http.NewRequest("PATCH", url, payload)
req.Header.Add("X-API-Key", "<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(string(body))
}HttpResponse<String> response = Unirest.patch("https://api.politicalcomms.com/v1/projects/{id}")
.header("X-API-Key", "<api-key>")
.header("Content-Type", "application/json")
.body("{\n \"name\": \"Spring Outreach (revised)\",\n \"message_text\": \"Hi {first_name}, early voting starts Monday. Learn more: {link}\",\n \"media_ids\": []\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.politicalcomms.com/v1/projects/{id}")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Patch.new(url)
request["X-API-Key"] = '<api-key>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"name\": \"Spring Outreach (revised)\",\n \"message_text\": \"Hi {first_name}, early voting starts Monday. Learn more: {link}\",\n \"media_ids\": []\n}"
response = http.request(request)
puts response.read_body{
"success": true,
"data": {
"project_id": "8a3f5c7e-2d91-4b64-ae08-6f1c9d3e7b52",
"organization_id": "0d9f6a2e-1b34-4c8e-9f21-3e7a8b5c4d10",
"channel": "10dlc",
"brand_id": "7c1de5f0-9a42-4b6d-8e33-2f5a9c7b1e64",
"campaign_id": "4b8fa3c1-6d25-4e9a-bf47-8c2e5d9a3f71",
"toll_free_verification_id": null,
"phone_number_ids": [
"b2e8d4f6-1a37-4c95-8e62-5d9f3b7a1c48"
],
"name": "July GOTV Blast",
"status": "ready",
"type": "broadcast",
"protocol": "sms",
"message_text": "Hi {{first_name}}, election day is coming up!",
"contact_list_ids": [
"3f7a9c1e-8b24-4d6f-a2e5-9c7b3f1a8d42"
],
"suppression_list_ids": [],
"media_ids": [],
"media_urls": [],
"link_tracking_enabled": true,
"link_tracking_destination_url": "https://smithcampaign.com/vote",
"link_tracking_domain_id": "6e2c9a4f-8b51-4d73-a9e6-1f4b8c5d2a90",
"link_tracking_param_field": "phone",
"link_tracking_fallback_url": null,
"opt_out_footer_enabled": true,
"ai_survey_analysis_enabled": false,
"total_recipients": 11800,
"estimated_cost_cents": 35400,
"scheduled_at": null,
"scheduled_timezone": null,
"created_via_api": true,
"created_at": "2025-07-01T14:00:00.000Z",
"updated_at": "2025-07-02T09:15:00.000Z",
"pause_reason": null,
"auto_paused": false,
"daily_cap_bypass": false
}
}Authorizations
Authenticate every request by passing your API key in the X-API-Key header. Keys are scoped to your organization hierarchy.
Headers
Optional idempotency key: a unique string of 16-200 printable ASCII characters (a UUID is recommended). Retrying the write with the same key within 24 hours returns the stored response of the first call with an X-Idempotent-Replayed: true response header instead of executing it again. Reusing a key with a different request body returns 422 (IDEMPOTENCY_MISMATCH); a duplicate sent while the first call is still running returns 409 with a Retry-After header. Keys are scoped per endpoint and organization.
16 - 200Path Parameters
Resource ID
Body
- Broadcast project update
- Survey project update
Message text. Supports merge tags ({first_name|there}, contact custom fields) and {tracking_url} when link tracking is enabled on the project. Limited to 10 SMS parts (1,530 GSM-7 or 670 Unicode characters, counted with merge tags removed), or 1,500 characters when the project is MMS (protocol, or the stored protocol when omitted) or media_ids is set; longer text is rejected with 400 and a message naming the limit.
1600sms, mms Change the messaging channel. Sending any of channel, brand_id, campaign_id, or toll_free_verification_id requires the full setup for the effective channel plus phone_number_ids (see the endpoint description). Switching to toll-free or short-code clears the brand and campaign.
10dlc, toll-free, short-code Required with campaign_id when the effective channel is 10dlc; omit otherwise. Must belong to the project's organization.
Required with brand_id when the effective channel is 10dlc; omit otherwise. Must belong to brand_id. The brand follows the campaign.
Required when the effective channel is toll-free; omit otherwise. Every number in phone_number_ids must be linked to this registration.
Replace the project's sending phone numbers (1-49). New conversations rotate across them; existing conversations keep their already-assigned sticky number.
1 - 49 elementsDeprecated. Use phone_number_ids. A single id is treated as a one-element phone_number_ids.
1Where tracking links redirect. May embed the selected link parameter via a {field} placeholder named after it (see POST /projects); a placeholder that does not match the effective link_tracking_param_field is rejected with a 400 (INVALID_LINK_PLACEHOLDER). May also be exactly 'https://{<link_tracking_param_field>}' to send each recipient to the URL in that contact field; then link_tracking_fallback_url is required. '{phone}' is not allowed as a whole URL.
Contact field carried on tracking-link redirects ('phone', a custom-field name, or null for none). Appended as a query pair, or embedded in place of a matching {field} placeholder in the destination URL.
64Redirect used when a recipient's link_tracking_param_field value is empty or not a valid URL. Required when link_tracking_destination_url is a whole-URL placeholder (e.g. 'https://{custom_url}'); rejected with a 400 (INVALID_LINK_PLACEHOLDER) otherwise.
Whether the 'STOP=END' opt-out footer is appended to every outbound message.
