Receive replies with webhooks
When someone texts your team number, RallyText can POST it to your server within about a minute. The same works for opt-outs, new members and delivery receipts. Webhooks are included on Team Plus and up.
1. Create a webhook
Add one in the dashboard under Settings → Developers, or with the API. Pick the events you want:
| Event | Sent when |
|---|---|
message.received | Someone texts your team number. |
message.status | A text you sent is delivered or fails, once per recipient. |
member.joined | Someone joins your roster. |
member.opted_out | A member texts STOP, support opts them out for you, or your integration records an opt-out. |
member.opted_in | A member who opted out texts START. |
webhook.test | You click Send test event in Settings → Developers. You can't subscribe to it. |
The response includes a signing secret (whsec_...). It is shown once, so store it with your other secrets.
curl -X POST https://rallytext.app/api/v1/webhooks \
-H "Authorization: Bearer rt_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/rallytext-webhook", "events": ["message.received", "member.opted_out"]}'
import os
import requests
API = "https://rallytext.app/api/v1"
HEADERS = {"Authorization": "Bearer " + os.environ["RALLYTEXT_API_KEY"]}
hook = requests.post(API + "/webhooks", headers=HEADERS, timeout=30, json={
"url": "https://example.com/rallytext-webhook",
"events": ["message.received", "member.opted_out"],
}).json()["data"]
print("Save this secret now, it is shown once:", hook["secret"])
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}/webhooks`, {
method: "POST", headers,
body: JSON.stringify({ url: "https://example.com/rallytext-webhook", events: ["message.received", "member.opted_out"] }),
});
const { data } = await resp.json();
console.log("Save this secret now, it is shown once:", data.secret);
})();
<?php
$ch = curl_init("https://rallytext.app/api/v1/webhooks");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("RALLYTEXT_API_KEY"), "Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode(["url" => "https://example.com/rallytext-webhook",
"events" => ["message.received", "member.opted_out"]]),
CURLOPT_RETURNTRANSFER => true,
]);
$hook = json_decode(curl_exec($ch), true)["data"];
curl_close($ch);
echo "Save this secret now, it is shown once: " . $hook["secret"] . "\n";
2. Verify every request
Each delivery is an HTTP POST with a JSON body and these headers:
| Header | Value |
|---|---|
X-RallyText-Event | The event name, for example message.received. |
X-RallyText-Delivery | A unique id for this delivery to this webhook. |
X-RallyText-Signature | t=<unix seconds>,v1=<hex> |
v1 is the hex HMAC-SHA256 of the timestamp, a period, and the raw body, keyed with your whole secret as UTF-8 text, whsec_ prefix included. Don't base64-decode the secret. Check the signature over the exact bytes you received, before parsing JSON. Compare in constant time, and reject timestamps more than 5 minutes from now. Each retry is signed again with a fresh timestamp, so a retried delivery still passes.
# Compute the signature RallyText would send, to compare with X-RallyText-Signature.
# Set SECRET (your whole whsec_... secret), BODY (the exact raw body) and T (the t= value).
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
import hashlib
import hmac
import time
def verify(secret, header, body, tolerance=300):
"""secret: your whole whsec_... string. header: X-RallyText-Signature.
body: the raw request bytes, before any JSON parsing."""
try:
parts = dict(p.split("=", 1) for p in header.split(","))
t, sig = parts["t"], parts["v1"]
if abs(time.time() - int(t)) > tolerance:
return False
except (KeyError, ValueError):
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected.encode(), sig.encode())
const crypto = require("crypto");
// secret: your whole whsec_... string. header: X-RallyText-Signature.
// rawBody: a Buffer of the exact request bytes, before JSON parsing.
function verify(secret, header, rawBody, tolerance = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split(/=(.*)/s).slice(0, 2)));
const t = parts.t, sig = parts.v1;
if (!t || !sig || !/^\d+$/.test(t)) return false;
if (Math.abs(Date.now() / 1000 - Number(t)) > tolerance) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(sig);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
<?php
// $secret: your whole whsec_... string. $header: X-RallyText-Signature.
// $body: the exact raw request body (file_get_contents("php://input")).
function verify(string $secret, string $header, string $body, int $tolerance = 300): bool {
$parts = [];
foreach (explode(",", $header) as $piece) {
$kv = explode("=", $piece, 2);
if (count($kv) === 2) { $parts[$kv[0]] = $kv[1]; }
}
if (!isset($parts["t"], $parts["v1"]) || !ctype_digit($parts["t"])) { return false; }
if (abs(time() - (int)$parts["t"]) > $tolerance) { return false; }
$expected = hash_hmac("sha256", $parts["t"] . "." . $body, $secret);
return hash_equals($expected, $parts["v1"]);
}
3. Handle the event
The body is an envelope: id (the event id), type, created_at, team (id and code) and data. For message.received, data has from, to, body, keyword, group_id, member (or null if the number isn't on your roster) and received_at.
Reply with any 2xx within 10 seconds. Redirects are not followed. Failed deliveries are retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours, then that delivery is dropped. Because of retries, the same event can arrive twice, so ignore event ids you have already handled. A retried event can also arrive after newer ones, so sort on created_at if order matters. After 20 failed attempts in a row the webhook is turned off and your head coaches and team directors get an email. Turn it back on in Settings → Developers once your endpoint works.
# Send your own endpoint a signed test event (SECRET as in the signature example).
BODY='{"id":"evt_test","type":"webhook.test","data":{"message":"hello"}}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -X POST https://example.com/rallytext-webhook \
-H "Content-Type: application/json" \
-H "X-RallyText-Event: webhook.test" \
-H "X-RallyText-Signature: t=$T,v1=$SIG" \
-d "$BODY"
import os
from flask import Flask, abort, request
# verify() is the function from the signature example above.
app = Flask(__name__)
SECRET = os.environ["RALLYTEXT_WEBHOOK_SECRET"]
seen = set() # use your database in production
@app.post("/rallytext-webhook")
def rallytext_webhook():
if not verify(SECRET, request.headers.get("X-RallyText-Signature", ""), request.get_data()):
abort(400)
event = request.get_json()
if event["id"] not in seen: # the same event can arrive twice
seen.add(event["id"])
if event["type"] == "message.received":
print("Reply from", event["data"]["from"], ":", event["data"]["body"])
return "", 200
const express = require("express");
// verify() is the function from the signature example above.
const app = express();
const SECRET = process.env.RALLYTEXT_WEBHOOK_SECRET;
const seen = new Set(); // use your database in production
app.post("/rallytext-webhook", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(SECRET, req.get("X-RallyText-Signature") || "", req.body)) return res.sendStatus(400);
const event = JSON.parse(req.body.toString("utf8"));
if (!seen.has(event.id)) { // the same event can arrive twice
seen.add(event.id);
if (event.type === "message.received") console.log(`Reply from ${event.data.from}: ${event.data.body}`);
}
res.sendStatus(200);
});
app.listen(3000);
<?php
// verify() is the function from the signature example above.
$secret = getenv("RALLYTEXT_WEBHOOK_SECRET");
$body = file_get_contents("php://input");
$header = $_SERVER["HTTP_X_RALLYTEXT_SIGNATURE"] ?? "";
if (!verify($secret, $header, $body)) { http_response_code(400); exit; }
$event = json_decode($body, true);
// Store $event["id"] and skip ids you have already handled: the same event can arrive twice.
if ($event["type"] === "message.received") {
error_log("Reply from " . $event["data"]["from"] . ": " . $event["data"]["body"]);
}
http_response_code(200);
What you can do with replies
Webhooks tell you about replies, but your code can't answer them through the API: replies go from the dashboard or a staff phone, which keeps Safe Sport rules in place. With the API you can mark texts read (POST /api/v1/inbox/{message_id}/read) so staff know they've been handled. Every event and field is listed in the webhooks reference.