# Textually API and webhooks

Add leads to a campaign from your own tools, look up where a lead is, read replies, and get an event at your URL when someone replies, opts out or finishes a campaign.

Updated 2026-10-14. Page: https://usetextually.com/developers

## 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.

List your campaigns:

```
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:

```
{
  "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.

Response:

```
{
  "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. |

Request:

```
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"
      }
    ]
  }'
```

Response:

```
{
  "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).

Response:

```
{
  "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.

Response:

```
{
  "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.

reply.received:

```
{
  "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) and `X-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 `id` to 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 + "." + body` with your secret and compare it to `v1` in constant time.
- Refuse the request if `t` is more than 5 minutes from your clock, so an old request can’t be replayed.

Node.js:

```
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));
}
```

Python:

```
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', ''))
```
