Get a key
An Owner or Admin creates a key in the app under Settings, API. The key is shown once; copy it then. Each key works for one workspace, and anyone who has it can add leads there, so keep it on your server and out of browsers and public code.
Every request goes to https://usetextually.com/api/v1 and carries the key in the Authorization header. Request and response bodies are JSON.
curl https://usetextually.com/api/v1/campaigns \
-H "Authorization: Bearer txt_live_your_key"A key stops working when it is revoked in Settings, or when the person who created it leaves the workspace.
Limits and errors
Each key can make 120 requests a minute. Every response carries X-RateLimit-Limit and X-RateLimit-Remaining. Past the limit you get a 429 with a Retry-After header in seconds.
Errors share one shape, with a code your program can check and a message a person can read:
{
"error": {
"code": "not_found",
"message": "No campaign with this id in your workspace."
}
}| Status | Meaning |
|---|---|
| 200 | Done. |
| 400 | Something in the request is missing or malformed; the message says what. |
| 401 | No key, a key we do not know, or a revoked key. |
| 404 | Nothing with that id in the key’s workspace. |
| 409 | The campaign has finished and takes no new leads. |
| 422 | None of the leads you sent could be added; each one says why. |
| 429 | Too many requests this minute. |
| 500 | Something failed on our side. Try again shortly. |
List campaigns
GET /campaigns returns the workspace’s campaigns, newest first. Add ?status=active (or draft, paused, completed) to filter.
{
"data": [
{
"id": "1e93b1a9-078b-4148-ad2a-49e25f2d07f8",
"name": "Spring open house",
"status": "active",
"created_at": "2026-10-14T16:17:37Z",
"updated_at": "2026-10-14T16:30:44Z"
}
]
}Add leads to a campaign
POST /campaigns/{campaign_id}/leads with up to 500 leads. Each lead needs a phone number and a record of the person’s consent to get texts: where they agreed and on what day. A lead without them is turned away on its own, and the rest still go in.
| Field | Required | What it is |
|---|---|---|
phone | Yes | A US or Canadian number. +14155550123, (415) 555-0123 and 4155550123 all work. |
consent_source | Yes | Where they agreed to get texts, like Web form or Signed form. Up to 200 characters. |
consent_date | Yes | The day they agreed, today or earlier: 2026-10-01 or 10/01/2026. |
first_name, last_name, full_name | No | Used in your texts’ merge fields. |
email, company_name, job_title | No | Kept with the lead. |
curl -X POST https://usetextually.com/api/v1/campaigns/CAMPAIGN_ID/leads \
-H "Authorization: Bearer txt_live_your_key" \
-H "Content-Type: application/json" \
-d '{
"leads": [
{
"phone": "+14155550123",
"first_name": "Ana",
"consent_source": "Web form",
"consent_date": "2026-10-01"
}
]
}'{
"added": [
{ "index": 0, "lead_id": "89b6d2b3-3fe9-49f4-81a4-a8f1b1c9581f", "phone": "+14155550123", "status": "to_launch" }
],
"rejected": [
{ "index": 1, "phone": "+14155550184", "code": "invalid_consent_date", "message": "consent_date is required: the day they agreed, today or earlier, like 2026-10-01." }
]
}index is the lead’s place in the list you sent, counting from 0. The response is 200 when at least one lead was added and 422 when none were.
New leads wait under To launch until someone launches them in the app. If the campaign is set to send new leads automatically and is running, they start right away (status is queued).
Why a lead is turned away
| Code | Why |
|---|---|
missing_phone | No phone number. |
invalid_phone | Not a US or Canadian number. Textually texts +1 numbers in the US and Canada only. |
missing_consent_source | No consent_source. |
invalid_consent_date | No consent_date, or a date we cannot read or that is in the future. |
invalid_email | The email does not look like one. Leave it out if you do not have one. |
invalid_field | A field is not text or is longer than 200 characters. |
duplicate_in_request | The same phone or email is earlier in the same request. |
already_in_campaign | This person is already in a campaign in the workspace. A person is in one campaign at a time. |
suppressed | The email is on the workspace’s do-not-contact list. |
Consent
Sending leads through the API is your statement that every person agreed to get texts from your business and that you can show where and when, the same statement you accept in the app under the Messaging Terms. Textually records it with the key, the person who created the key, the campaign and the number of leads, and lists it under Settings, Compliance. Texts still go out only from your registered numbers, with the opt-out line, and never to anyone who replied STOP.
Look up a lead
GET /leads/{lead_id} returns one lead. GET /leads?phone=+14155550123 returns the leads with that number in your workspace (usually one).
{
"data": {
"id": "89b6d2b3-3fe9-49f4-81a4-a8f1b1c9581f",
"campaign": { "id": "1e93b1a9-078b-4148-ad2a-49e25f2d07f8", "name": "Spring open house" },
"phone": "+14155550123",
"first_name": "Ana",
"last_name": "Ruiz",
"email": "ana@example.com",
"company_name": null,
"job_title": null,
"status": "replied",
"steps_done": 1,
"texts_sent": 1,
"last_text_at": "2026-10-14T16:32:04Z",
"last_delivery_status": "delivered",
"last_reply_at": "2026-10-14T18:48:26Z",
"opted_out_at": null,
"consent": { "source": "Web form", "date": "2026-10-01" },
"created_at": "2026-10-14T16:28:03Z",
"updated_at": "2026-10-14T18:48:27Z"
}
}status | Meaning |
|---|---|
waiting | Not launched yet, queued for the first text, or paused. |
in_progress | Partway through the campaign’s steps. |
finished | Went through every step. |
replied | Texted back, so the steps stopped. |
opted_out | Asked to get no more texts. |
skipped | Done without getting a text: no consent on record, a landline, or a number outside the US and Canada. |
last_delivery_status is the carrier’s latest report on the last text: queued, sent, delivered, undelivered or failed.
List replies
GET /replies returns texts people sent back, newest first, opt-outs included with is_opt_out set to true. Filter with since and before (ISO 8601 times) and campaign_id; limit is 1 to 200, 50 by default. To page back, pass the last reply’s received_at as before; has_more says whether there may be more.
{
"data": [
{
"id": "51fa9f82-6b90-43e8-a70d-8e745ede7d0d",
"text": "Yes, call me tomorrow",
"from": "+14155550123",
"to": "+12025550199",
"received_at": "2026-10-14T20:29:27Z",
"is_opt_out": false,
"campaign_id": "1e93b1a9-078b-4148-ad2a-49e25f2d07f8",
"lead_id": "89b6d2b3-3fe9-49f4-81a4-a8f1b1c9581f"
}
],
"has_more": false
}Webhooks
An Owner or Admin adds a webhook in Settings, API: a URL starting with https:// and the events to send. Textually then sends a POST to that URL within about a minute of each event. Send test in Settings sends a ping event so you can check your endpoint.
| Event | Sent when | data holds |
|---|---|---|
reply.received | Someone texts back. | reply, lead, campaign |
lead.opted_out | Someone replies STOP or another opt-out word. | reply, lead, campaign |
lead.finished | A lead went through every step. | lead, campaign |
campaign.finished | Every lead in a campaign is done. | campaign with its results |
lead and campaign have the same shape as in the API; lead is null when a text comes from a number that is in no campaign.
{
"id": "93f80048-b07e-4136-a539-5490e50bd089",
"type": "reply.received",
"created_at": "2026-10-14T20:29:27Z",
"workspace_id": "6f29f73d-bb9b-40d8-b368-af32bfd3235f",
"data": {
"reply": { "id": "51fa9f82-…", "text": "Yes, call me tomorrow", "from": "+14155550123", "is_opt_out": false, … },
"lead": { "id": "89b6d2b3-…", "status": "replied", … },
"campaign": { "id": "1e93b1a9-…", "name": "Spring open house", "status": "active", … }
}
}- Each request has the headers
X-Textually-Event(the event),X-Textually-Delivery(an id for this delivery, the same on every retry) andX-Textually-Signature. - Answer with any 2xx status within 10 seconds. Anything else counts as a failure and is tried again, up to 5 tries in all, waiting longer each time.
- The same event can arrive twice. Use the payload’s
idto ignore repeats. - Settings, API shows each webhook’s recent deliveries with the status code your server returned.
Check the signature
When you add a webhook, Textually shows its signing secret once (it starts with whsec_). Each request carries X-Textually-Signature: t=1792000000,v1=5257a8…: t is the time it was sent in Unix seconds and v1 is an HMAC-SHA256, in hex, of the text t, a full stop, and the raw request body, keyed with the secret.
- Read the raw body before parsing it as JSON; parsing and re-serializing changes the bytes.
- Compute the HMAC of
t + "." + bodywith your secret and compare it tov1in constant time. - Refuse the request if
tis more than 5 minutes from your clock, so an old request can’t be replayed.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function isFromTextually(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const t = Number(parts.t);
if (!t || !parts.v1 || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return expected.length === parts.v1.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}import hashlib, hmac, time
def is_from_textually(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split('=', 1) for p in header.split(','))
t = int(parts.get('t', 0))
if not t or abs(time.time() - t) > 300:
return False
expected = hmac.new(secret.encode(), f'{t}.'.encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get('v1', ''))Questions or something missing? Write to us from the app, or apply for access if your team is not on Textually yet.