Sync members from a spreadsheet
Keep your RallyText roster in step with a registration export, a club database or a shared sheet. For a one-time upload, the dashboard's CSV import (Roster → Import) needs no code.
1. Check the numbers
POST /api/v1/recipients/validate checks up to 100 numbers at once: is each one a valid US number, is it already on your roster, and did it text STOP to your team. It changes nothing, and it doesn't call the phone carriers.
curl -X POST https://rallytext.app/api/v1/recipients/validate \
-H "Authorization: Bearer rt_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{"phones": ["(906) 555-1234", "906-555-9876", "not a number"]}'
import os
import requests
API = "https://rallytext.app/api/v1"
HEADERS = {"Authorization": "Bearer " + os.environ["RALLYTEXT_API_KEY"]}
resp = requests.post(API + "/recipients/validate", headers=HEADERS, timeout=30,
json={"phones": ["(906) 555-1234", "906-555-9876", "not a number"]})
resp.raise_for_status()
for c in resp.json()["data"]:
if not c["valid"]:
print(c["input"], "-> not a valid US number")
elif c["opted_out"]:
print(c["input"], "-> texted STOP, skip it")
elif c["member_ids"]:
print(c["input"], "-> already on the roster:", c["member_ids"])
else:
print(c["input"], "-> new,", c["phone"])
const API = "https://rallytext.app/api/v1";
const headers = { Authorization: `Bearer ${process.env.RALLYTEXT_API_KEY}`, "Content-Type": "application/json" };
(async () => {
const resp = await fetch(`${API}/recipients/validate`, {
method: "POST",
headers,
body: JSON.stringify({ phones: ["(906) 555-1234", "906-555-9876", "not a number"] }),
});
const body = await resp.json();
if (!resp.ok) throw new Error(JSON.stringify(body.error));
for (const c of body.data) {
if (!c.valid) console.log(c.input, "-> not a valid US number");
else if (c.opted_out) console.log(c.input, "-> texted STOP, skip it");
else if (c.member_ids.length) console.log(c.input, "-> already on the roster:", c.member_ids);
else console.log(c.input, "-> new,", c.phone);
}
})();
<?php
$ch = curl_init("https://rallytext.app/api/v1/recipients/validate");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("RALLYTEXT_API_KEY"), "Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode(["phones" => ["(906) 555-1234", "906-555-9876", "not a number"]]),
CURLOPT_RETURNTRANSFER => true,
]);
$body = json_decode(curl_exec($ch), true);
curl_close($ch);
if (isset($body["error"])) { throw new RuntimeException(json_encode($body["error"])); }
foreach ($body["data"] as $c) {
if (!$c["valid"]) { echo $c["input"] . " -> not a valid US number\n"; }
elseif ($c["opted_out"]) { echo $c["input"] . " -> texted STOP, skip it\n"; }
elseif ($c["member_ids"]) { echo $c["input"] . " -> already on the roster\n"; }
else { echo $c["input"] . " -> new, " . $c["phone"] . "\n"; }
}
2. Add or update each row
Try to add each person with POST /api/v1/members. A 409 member_exists response includes existing_member_id, so you can update that member with PATCH /api/v1/members/{member_id} instead. A 409 opted_out means that number texted STOP: skip it. Only the member can opt back in, by texting START to your team number.
# members.csv: name,phone,email,role with a header row and no commas inside values.
# A 409 member_exists reply includes existing_member_id: update that member with
# PATCH https://rallytext.app/api/v1/members/{existing_member_id}
tail -n +2 members.csv | while IFS=, read -r name phone email role; do
curl -s -X POST https://rallytext.app/api/v1/members \
-H "Authorization: Bearer rt_live_your_key_here" \
-H "Content-Type: application/json" \
-d "{\"name\": \"$name\", \"phone\": \"$phone\", \"email\": \"$email\", \"role\": \"${role:-athlete}\"}"
echo
sleep 0.6 # stay under 120 requests a minute
done
import csv
import os
import time
import requests
API = "https://rallytext.app/api/v1"
HEADERS = {"Authorization": "Bearer " + os.environ["RALLYTEXT_API_KEY"]}
def call(method, path, body):
resp = requests.request(method, API + path, headers=HEADERS, json=body, timeout=30)
time.sleep(0.6) # stay under 120 requests a minute
return resp
with open("members.csv", newline="", encoding="utf-8") as fh:
for row in csv.DictReader(fh): # columns: name, phone, email, role
member = {"name": row["name"], "phone": row["phone"], "role": row.get("role") or "athlete"}
if row.get("email"):
member["email"] = row["email"]
resp = call("POST", "/members", member)
if resp.ok:
print("Added", row["name"])
continue
err = resp.json()["error"]
if err["code"] == "member_exists":
member.pop("phone")
update = call("PATCH", f"/members/{err['existing_member_id']}", member)
if update.ok:
print("Updated", row["name"])
else: # staff_member_protected: staff are managed in the dashboard
print("Not updated", row["name"], "-", update.json()["error"]["code"])
else: # opted_out: only the member can opt back in, by texting START
print("Skipped", row["name"], "-", err["code"])
const fs = require("fs");
const API = "https://rallytext.app/api/v1";
const headers = { Authorization: `Bearer ${process.env.RALLYTEXT_API_KEY}`, "Content-Type": "application/json" };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function call(method, path, member) {
const resp = await fetch(`${API}${path}`, { method, headers, body: JSON.stringify(member) });
await sleep(600); // stay under 120 requests a minute
return [resp.ok, await resp.json()];
}
(async () => {
// members.csv: name,phone,email,role with a header row; use a CSV library for quoted values
const [header, ...lines] = fs.readFileSync("members.csv", "utf8").trim().split(/\r?\n/);
const cols = header.split(",");
for (const line of lines) {
const row = Object.fromEntries(line.split(",").map((v, i) => [cols[i], v.trim()]));
const member = { name: row.name, phone: row.phone, role: row.role || "athlete" };
if (row.email) member.email = row.email;
const [added, body] = await call("POST", "/members", member);
if (added) {
console.log("Added", row.name);
} else if (body.error.code === "member_exists") {
delete member.phone;
const [updated, out] = await call("PATCH", `/members/${body.error.existing_member_id}`, member);
console.log(updated ? "Updated" : "Not updated", row.name, updated ? "" : out.error.code);
} else {
console.log("Skipped", row.name, "-", body.error.code); // opted_out: only they can text START
}
}
})();
<?php
function rt(string $method, string $path, array $body): array {
$ch = curl_init("https://rallytext.app/api/v1" . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("RALLYTEXT_API_KEY"), "Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode($body),
CURLOPT_RETURNTRANSFER => true,
]);
$out = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
usleep(600000); // stay under 120 requests a minute
return [$status, $out];
}
$fh = fopen("members.csv", "r");
$cols = fgetcsv($fh); // name, phone, email, role
while (($values = fgetcsv($fh)) !== false) {
$row = array_combine($cols, $values);
$member = ["name" => $row["name"], "phone" => $row["phone"], "role" => $row["role"] ?: "athlete"];
if ($row["email"]) { $member["email"] = $row["email"]; }
[$status, $out] = rt("POST", "/members", $member);
if ($status < 300) {
echo "Added {$row["name"]}\n";
} elseif ($out["error"]["code"] === "member_exists") {
unset($member["phone"]);
[$status, $upd] = rt("PATCH", "/members/" . $out["error"]["existing_member_id"], $member);
echo ($status < 300 ? "Updated" : "Not updated") . " {$row["name"]}\n";
} else {
echo "Skipped {$row["name"]} - {$out["error"]["code"]}\n"; // opted_out: only they can text START
}
}
fclose($fh);
Staff (coaches, team directors, board members) and anyone with a dashboard login are managed in the dashboard, so the API returns 403 staff_member_protected for them. To store extra columns such as jersey numbers, add custom fields with POST /api/v1/custom-fields and set them with PATCH /api/v1/members/{member_id}/custom-fields. Values of a field your team marked Private can be written but are never returned.
3. Bring over your unsubscribe list
If you are moving from another texting service, record its opt-outs before your first send with POST /api/v1/opt-outs/bulk (up to 500 numbers a call). Each number is opted out on every roster entry it appears on, and a number that isn't on your roster yet is recorded too, so the API won't add it later. RallyText doesn't text these people to confirm. An opt-out is permanent through the API: only the member can undo it, by texting START. A staff member's number is refused (staff_member_protected), because staff opt out by texting STOP themselves. This works on any plan.
curl -X POST https://rallytext.app/api/v1/opt-outs/bulk \
-H "Authorization: Bearer rt_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{"items": [{"phone": "(906) 555-2222"}, {"phone": "+19065553333"}]}'
import os
import time
import requests
API = "https://rallytext.app/api/v1"
HEADERS = {"Authorization": "Bearer " + os.environ["RALLYTEXT_API_KEY"]}
# unsubscribes.txt: one phone number per line, from your old texting service
with open("unsubscribes.txt", encoding="utf-8") as fh:
phones = [line.strip() for line in fh if line.strip()]
for start in range(0, len(phones), 500): # up to 500 per call
chunk = [{"phone": p} for p in phones[start:start + 500]]
resp = requests.post(API + "/opt-outs/bulk", headers=HEADERS, json={"items": chunk}, timeout=60)
resp.raise_for_status()
for r in resp.json()["data"]["results"]:
if r["status"] == "error":
print("Line", start + r["index"] + 1, r["error"]["code"])
time.sleep(6) # bulk calls are limited to 10 a minute
const fs = require("fs");
const API = "https://rallytext.app/api/v1";
const headers = { Authorization: `Bearer ${process.env.RALLYTEXT_API_KEY}`, "Content-Type": "application/json" };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
(async () => {
const phones = fs.readFileSync("unsubscribes.txt", "utf8").split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
for (let start = 0; start < phones.length; start += 500) { // up to 500 per call
const items = phones.slice(start, start + 500).map((phone) => ({ phone }));
const resp = await fetch(`${API}/opt-outs/bulk`, { method: "POST", headers, body: JSON.stringify({ items }) });
const body = await resp.json();
if (!resp.ok) throw new Error(JSON.stringify(body.error));
for (const r of body.data.results) {
if (r.status === "error") console.log("Line", start + r.index + 1, r.error.code);
}
await sleep(6000); // bulk calls are limited to 10 a minute
}
})();
<?php
$phones = array_values(array_filter(array_map("trim", file("unsubscribes.txt"))));
foreach (array_chunk($phones, 500) as $n => $chunk) { // up to 500 per call
$ch = curl_init("https://rallytext.app/api/v1/opt-outs/bulk");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("RALLYTEXT_API_KEY"), "Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode(["items" => array_map(fn($p) => ["phone" => $p], $chunk)]),
CURLOPT_RETURNTRANSFER => true,
]);
$body = json_decode(curl_exec($ch), true);
curl_close($ch);
if (isset($body["error"])) { throw new RuntimeException(json_encode($body["error"])); }
foreach ($body["data"]["results"] as $r) {
if ($r["status"] === "error") { echo "Line " . ($n * 500 + $r["index"] + 1) . " " . $r["error"]["code"] . "\n"; }
}
sleep(6); // bulk calls are limited to 10 a minute
}
Limits
Each key gets 120 requests a minute, so the scripts above wait 0.6 seconds after each request. Bulk opt-outs, number checks, delivery reports and analytics share a separate limit of 10 calls a minute, so the opt-out script waits 6 seconds between calls. A 429 rate_limited response means slow down and retry.