docs

Docs › Get started

Go live: 10DLC

US carriers require every application-to-person text from a 10-digit number to be registered with The Campaign Registry (TCR), and unregistered traffic is blocked. Handset wraps TCR behind three API calls. Test mode approves instantly, so you can build the whole flow before you register for real.

The flow

  1. Register a brand. Your company, once.
  2. Create a campaign per tenant, under that brand.
  3. Attach the campaign to each of the tenant's numbers, at purchase or later with PATCH.

Sending from a live number without an approved campaign fails with 422 campaign_not_approved.

1. Register a brand

Required: legal_name, ein (US tax ID), entity_type (private_company, public_company, non_profit, or sole_proprietor), contact_email, phone (E.164 US: +1 and 10 digits), street, city, state (2 letters), and postal_code (ZIP or ZIP+4). Optional: dba, website, tenant_id.

Omit tenant_id for the normal case: one platform-level brand owned by you, reused by every tenant's campaign. Set it only when a tenant registers under its own EIN.

cURL
curl https://api.handset.dev/v1/brands \
  -H "Authorization: Bearer $HANDSET_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "legal_name": "Acme Software Inc.",
    "ein": "12-3456789",
    "entity_type": "private_company",
    "contact_email": "compliance@acme.example",
    "phone": "+14155550100",
    "street": "1 Market St",
    "city": "San Francisco",
    "state": "CA",
    "postal_code": "94105"
  }'

{ "id": "brd_01k…", "status": "pending_vetting", "rejection_reason": null, "ein": "**-***6789", … }
TypeScript
import { createBrand } from "@handset/sdk";

const { data: brand } = await createBrand({
  body: {
    legal_name: "Acme Software Inc.",
    ein: "12-3456789",
    entity_type: "private_company",
    contact_email: "compliance@acme.example",
    phone: "+14155550100",
    street: "1 Market St",
    city: "San Francisco",
    state: "CA",
    postal_code: "94105",
  },
});
// brand → { id: "brd_01k…", status: "pending_vetting", ein: "**-***6789", … }

Status moves pending_vetting to vetted or rejected. Vetting typically takes minutes to a few days.

2. Create a campaign

A campaign describes what one tenant sends. Required: tenant_id; brand_id (the brand must be vetted, otherwise 422 brand_not_vetted); use_case (customer_care, appointment_reminders, marketing, two_factor, or mixed); description (what the tenant sends and why recipients expect it); sample_messages (2 to 5 representative messages); and opt_in_description (how recipients consent, at least 40 characters). Carriers read the samples and the opt-in description, so write them for a reviewer.

cURL
curl https://api.handset.dev/v1/campaigns \
  -H "Authorization: Bearer $HANDSET_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "tenant_id": "tnt_01k…",
    "brand_id": "brd_01k…",
    "use_case": "appointment_reminders",
    "description": "Bayview Dental texts patients appointment reminders and replies to their questions.",
    "sample_messages": [
      "Bayview Dental: your cleaning is tomorrow at 2pm. Reply C to confirm. Reply STOP to opt out.",
      "Bayview Dental: we have an opening Friday at 9am if you want to move your visit. Reply STOP to opt out."
    ],
    "opt_in_description": "Patients give their mobile number and tick an SMS consent checkbox on the intake form at bayviewdental.example/new-patient."
  }'

{ "id": "cmp_01k…", "status": "pending_review", "throughput": null, … }
TypeScript
import { createCampaign } from "@handset/sdk";

const { data: campaign } = await createCampaign({
  body: {
    tenant_id: "tnt_01k…",
    brand_id: "brd_01k…",
    use_case: "appointment_reminders",
    description: "Bayview Dental texts patients appointment reminders and replies to their questions.",
    sample_messages: [
      "Bayview Dental: your cleaning is tomorrow at 2pm. Reply C to confirm. Reply STOP to opt out.",
      "Bayview Dental: we have an opening Friday at 9am if you want to move your visit. Reply STOP to opt out.",
    ],
    opt_in_description: "Patients give their mobile number and tick an SMS consent checkbox on the intake form at bayviewdental.example/new-patient.",
  },
});
// campaign → { id: "cmp_01k…", status: "pending_review", … }

Status moves draft, pending_review, then approved, rejected, or suspended. Carrier review typically takes a few business days. Once granted, the campaign carries throughput with messages_per_minute and daily_cap.

3. Attach it to numbers

Pass campaign_id when you buy a number, or attach it later with PATCH. Attaching registers the number at the carrier. Once the campaign is approved, the number reports messaging_ready: true. Releasing a number unassigns it automatically.

cURL
# At purchase…
curl https://api.handset.dev/v1/phone_numbers \
  -H "Authorization: Bearer $HANDSET_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"tenant_id": "tnt_01k…", "phone_number": "+14155550134", "campaign_id": "cmp_01k…"}'

# …or on a number you already own.
curl -X PATCH https://api.handset.dev/v1/phone_numbers/num_01k… \
  -H "Authorization: Bearer $HANDSET_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"campaign_id": "cmp_01k…"}'
TypeScript
import { purchaseNumber, updateNumber } from "@handset/sdk";

// At purchase…
await purchaseNumber({
  body: { tenant_id: "tnt_01k…", phone_number: "+14155550134", campaign_id: "cmp_01k…" },
});

// …or on a number you already own.
await updateNumber({
  path: { number_id: "num_01k…" },
  body: { campaign_id: "cmp_01k…" },
});

What it costs

The Campaign Registry and the carriers charge for registration, and Handset passes those fees through at cost on live registrations, drawn from your prepaid balance and itemized in the console:

ItemFeeWhen
Brand registration$4Once, when the live brand is created
Campaign setup$50Once, when the live campaign is created
Campaign monthly$10Each month from creation while the campaign is pending or approved

Test-mode brands and campaigns are free. Live keys need a balance of at least $20 so a fresh account can cover its first registration.

Tracking status

Handset polls the registry every 5 minutes and emits brand.status_changed and campaign.status_changed webhooks. Test mode emits the same events, just immediately. A rejection carries rejection_reason on the object; fix the problem and create a new brand or campaign.

The console has the same forms at console.handset.dev/compliance, and the Numbers page shows Campaign needed with an Attach campaign action.

Tips for approval

  • One brand, many campaigns is the vertical-SaaS pattern: register your company once and reuse it for every tenant.
  • Pick customer_care or mixed unless the tenant truly markets.
  • Make sample messages look like real sends, with the brand name and "Reply STOP to opt out".
  • Describe the actual consent moment: a form URL, a checkbox, a verbal script.
  • Build and test the whole flow in test mode first.

Errors you may see

CodeMeaning
campaign_not_approvedSending from a live number whose campaign is missing or not yet approved (422)
brand_not_vettedThe campaign's brand has not finished vetting (422)
brand_not_foundNo brand with that brand_id
campaign_not_foundNo campaign with that campaign_id
invalid_use_caseuse_case is not one of the allowed values
missing_fieldsA required field is absent from the request

Full details for every code are on the Errors page.