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
- Register a brand. Your company, once.
- Create a campaign per tenant, under that brand.
- 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 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", … }
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 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, … }
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.
# 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…"}'
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:
| Item | Fee | When |
|---|---|---|
| Brand registration | $4 | Once, when the live brand is created |
| Campaign setup | $50 | Once, when the live campaign is created |
| Campaign monthly | $10 | Each 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_careormixedunless 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
| Code | Meaning |
|---|---|
campaign_not_approved | Sending from a live number whose campaign is missing or not yet approved (422) |
brand_not_vetted | The campaign's brand has not finished vetting (422) |
brand_not_found | No brand with that brand_id |
campaign_not_found | No campaign with that campaign_id |
invalid_use_case | use_case is not one of the allowed values |
missing_fields | A required field is absent from the request |
Full details for every code are on the Errors page.