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

Version 1. Updated October 14, 2026. Base URL https://usetextually.com/api/v1

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."
  }
}
StatusMeaning
200Done.
400Something in the request is missing or malformed; the message says what.
401No key, a key we do not know, or a revoked key.
404Nothing with that id in the key’s workspace.
409The campaign has finished and takes no new leads.
422None of the leads you sent could be added; each one says why.
429Too many requests this minute.
500Something 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.

FieldRequiredWhat it is
phoneYesA US or Canadian number. +14155550123, (415) 555-0123 and 4155550123 all work.
consent_sourceYesWhere they agreed to get texts, like Web form or Signed form. Up to 200 characters.
consent_dateYesThe day they agreed, today or earlier: 2026-10-01 or 10/01/2026.
first_name, last_name, full_nameNoUsed in your texts’ merge fields.
email, company_name, job_titleNoKept 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

CodeWhy
missing_phoneNo phone number.
invalid_phoneNot a US or Canadian number. Textually texts +1 numbers in the US and Canada only.
missing_consent_sourceNo consent_source.
invalid_consent_dateNo consent_date, or a date we cannot read or that is in the future.
invalid_emailThe email does not look like one. Leave it out if you do not have one.
invalid_fieldA field is not text or is longer than 200 characters.
duplicate_in_requestThe same phone or email is earlier in the same request.
already_in_campaignThis person is already in a campaign in the workspace. A person is in one campaign at a time.
suppressedThe 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"
  }
}
statusMeaning
waitingNot launched yet, queued for the first text, or paused.
in_progressPartway through the campaign’s steps.
finishedWent through every step.
repliedTexted back, so the steps stopped.
opted_outAsked to get no more texts.
skippedDone 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.

EventSent whendata holds
reply.receivedSomeone texts back.reply, lead, campaign
lead.opted_outSomeone replies STOP or another opt-out word.reply, lead, campaign
lead.finishedA lead went through every step.lead, campaign
campaign.finishedEvery 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', ''))

Questions or something missing? Write to us from the app, or apply for access if your team is not on Textually yet.