# Messaging (10DLC)

> Register the workspace for US A2P 10DLC texting: check status, file the brand and campaign, refresh, and verify sole-proprietor PINs.

Source: https://dev.ody.co/api/messaging

US carriers block application-to-person texts from local numbers unless the sender is registered with **The Campaign Registry (TCR)**. The registration has two parts: a **brand** (who you are) and a **campaign** (what you text and how people opt in). This resource runs that registration for the workspace. It's the same pipeline as **Settings → Texting** in the app.

## The pipeline

```text
none → profile_ready → brand_pending ──→ brand_ok → campaign_pending → campaign_active ✓ canText
                             │                              │
                             ├→ brand_otp_pending (sole proprietors: submit the PIN)
                             └→ brand_failed                └→ campaign_failed
```

Carrier review usually takes minutes to a few days. Poll [`GET /messaging`](#retrieve-registration-status), or call [`POST /messaging/refresh`](#refresh-status) to re-check right away.

## The messaging status object

- `step` *string* — Where the registration is. See the pipeline above.
- `canText` *boolean* — `true` once the campaign is carrier-approved. Until then, `POST /messages` returns `409`.
- `messagingProfileId` *string | null* — The carrier messaging profile.
- `brand` *object | null* — `status`, `identityStatus`, `failureReasons`. For sole proprietors, also `otpSentAt` and `otpMobileLast4`.
- `campaign` *object | null* — Campaign submission and carrier status.

**`GET /v1/api/messaging`**

### Retrieve registration status

**Scope:** `numbers:manage`

**Returns** `{ messaging }`.

cURL:

```bash
curl https://api.ody.co/v1/api/messaging \
  -H "Authorization: Bearer $ODY_API_KEY"
```
TypeScript:

```ts
const status = await ody.messaging.status();
if (status.canText) console.log("Ready to text");
```
Python:

```python
status = client.messaging.status()
```

Response:

```json
{
  "messaging": {
    "step": "campaign_pending",
    "canText": false,
    "messagingProfileId": "40019…",
    "brand": { "status": "OK", "identityStatus": "VERIFIED", "failureReasons": [] },
    "campaign": { "status": "PENDING" }
  }
}
```

**`POST /v1/api/messaging/registration`**

### Submit a registration

Files the brand with TCR and stages the campaign. The campaign is created automatically once the brand is accepted.

> **Submitting is a purchase:**
A live filing **ends a running free trial** and charges the plan, plus the one-time **$20 setup fee per local number**, to the card on file **immediately**, before carriers accept anything. A resubmission never charges the same number again. The $4/mo messaging add-on starts when the campaign is approved.

- A trialing workspace must send `acknowledge_trial_end: true`, confirming the customer was told. Otherwise you get `409`.
- With no payment method, a canceled subscription, or (while trialing) no submitted identity check, you get `409` "Add a payment method…".
- If the card declines, nothing is filed.
- The owner gets an emailed receipt with a masked copy of the application, and every filing is audit-logged with actor `api`.

With a **test key**, the filing always runs in mock mode: the full pipeline runs, but nothing is submitted to TCR, no fee is charged and the billing gate is skipped.

**Scope:** `numbers:manage`

**Body**

- `business` *object* **required** — The legal identity. See below.
- `campaign` *object* — Campaign settings. Sensible defaults are applied when you omit it.
- `acknowledge_trial_end` *boolean* — Required (`true`) on a free trial.
- `mock` *boolean* — Force mock mode on a live key (no fee, nothing filed).

**`business`**

- `entityType` *string* — `PRIVATE_PROFIT` (default), `PUBLIC_PROFIT`, `NON_PROFIT`, `GOVERNMENT` or `SOLE_PROPRIETOR`.
- `displayName` *string* **required** — The brand name customers see.
- `email` *string* **required**
- `vertical` *string* **required** — TCR vertical, for example `PROFESSIONAL`, `RETAIL`, `REAL_ESTATE`.
- `street`, `city`, `state`, `postalCode` *string* **required**
- `companyName`, `ein` *string* — Required unless `SOLE_PROPRIETOR`. EIN is 9 digits; the dash is optional.
- `firstName`, `lastName`, `mobilePhone` *string* — Required for `SOLE_PROPRIETOR`. TCR texts a PIN to `mobilePhone`.
- `phone` *string* — Support phone, in E.164 or 10-digit US format.
- `website` *string*
- `country` *string* — ISO 3166 alpha-2. Default `US`.

**`campaign`**

- `useCase` *string* — Default `MIXED`.
- `description` *string* — At least 40 characters. Built from the business name and use case when omitted.
- `samples` *array of strings* — Up to 5 example messages.
- `optIn` *object* — **Preferred.** `{ method: "web_form", url, location, formConfirmed: true, screenshotUrl? }`. Ody writes a carrier-ready opt-in description from it. It overrides `messageFlow`.
- `messageFlow` *string* — 40–2048 characters describing **one** concrete opt-in method. Carriers reject descriptions that list several methods or point at a form that doesn't exist.
- `optinMessage`, `helpMessage` *string*
- `optinKeywords` *string* — Default `START`.
- `embeddedLink`, `embeddedPhone` *boolean* — Whether your messages contain links or phone numbers.

**Returns** `201` with `{ messaging, mock? }`. A `409` means TCR or the carrier rejected the filing (the message says why) or a billing gate blocked it.

cURL:

```bash
curl https://api.ody.co/v1/api/messaging/registration \
  -H "Authorization: Bearer $ODY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "business": {
      "displayName": "Acme Plumbing",
      "companyName": "Acme Plumbing LLC",
      "ein": "12-3456789",
      "email": "owner@acmeplumbing.com",
      "vertical": "PROFESSIONAL",
      "street": "100 Congress Ave",
      "city": "Austin",
      "state": "TX",
      "postalCode": "78701",
      "website": "https://acmeplumbing.com"
    },
    "campaign": {
      "useCase": "CUSTOMER_CARE",
      "samples": ["Hi Ada, your plumber is on the way and will arrive by 2pm. Reply STOP to opt out."],
      "optIn": {
        "method": "web_form",
        "url": "https://acmeplumbing.com/contact",
        "location": "the quote form at the bottom of the contact page",
        "formConfirmed": true
      }
    },
    "acknowledge_trial_end": true
  }'
```
TypeScript:

```ts
const { messaging } = await ody.messaging.register({
  business: {
    displayName: "Acme Plumbing",
    companyName: "Acme Plumbing LLC",
    ein: "12-3456789",
    email: "owner@acmeplumbing.com",
    vertical: "PROFESSIONAL",
    street: "100 Congress Ave",
    city: "Austin",
    state: "TX",
    postalCode: "78701",
  },
  campaign: {
    useCase: "CUSTOMER_CARE",
    optIn: { method: "web_form", url: "https://acmeplumbing.com/contact", location: "the quote form at the bottom of the contact page", formConfirmed: true },
  },
  acknowledgeTrialEnd: true,
});
```
Python:

```python
res = client.messaging.register(
    business={
        "displayName": "Acme Plumbing",
        "companyName": "Acme Plumbing LLC",
        "ein": "12-3456789",
        "email": "owner@acmeplumbing.com",
        "vertical": "PROFESSIONAL",
        "street": "100 Congress Ave",
        "city": "Austin",
        "state": "TX",
        "postalCode": "78701",
    },
    campaign={"useCase": "CUSTOMER_CARE", "optIn": {"method": "web_form", "url": "https://acmeplumbing.com/contact", "location": "the quote form at the bottom of the contact page", "formConfirmed": True}},
    acknowledge_trial_end=True,
)
```

**`POST /v1/api/messaging/refresh`**

### Refresh status

Asks the carrier for the latest brand and campaign status and moves the pipeline forward (for example, it creates the campaign once the brand is accepted).

**Scope:** `numbers:manage`

**Returns** `{ messaging }`.

cURL:

```bash
curl -X POST https://api.ody.co/v1/api/messaging/refresh \
  -H "Authorization: Bearer $ODY_API_KEY"
```
TypeScript:

```ts
const status = await ody.messaging.refresh();
```
Python:

```python
status = client.messaging.refresh()
```

**`POST /v1/api/messaging/otp/verify`**

### Verify a sole-proprietor PIN

Only used while `step` is `brand_otp_pending`. When a `SOLE_PROPRIETOR` brand is filed, TCR texts a PIN to `business.mobilePhone`. Submit it here. A correct PIN verifies the brand and creates the campaign immediately, so the response `step` is usually `campaign_pending`. PINs expire after 24 hours.

**Scope:** `numbers:manage`

**Body**

- `pin` *string* **required**

**Returns** `{ messaging }`. Returns `400` for a wrong or expired PIN, or `409` if the registration isn't waiting for one.

cURL:

```bash
curl https://api.ody.co/v1/api/messaging/otp/verify \
  -H "Authorization: Bearer $ODY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"pin":"482913"}'
```
TypeScript:

```ts
await ody.messaging.verifyOtp("482913");
```
Python:

```python
client.messaging.verify_otp("482913")
```

**`POST /v1/api/messaging/otp/resend`**

### Resend a sole-proprietor PIN

Texts a fresh PIN to `business.mobilePhone`. You can request one per minute; asking again sooner returns `429`.

**Scope:** `numbers:manage`

**Returns** `{ messaging }`.

cURL:

```bash
curl -X POST https://api.ody.co/v1/api/messaging/otp/resend \
  -H "Authorization: Bearer $ODY_API_KEY"
```
TypeScript:

```ts
await ody.messaging.resendOtp();
```
Python:

```python
client.messaging.resend_otp()
```
