Cabalmail

Host your own email and enhance your privacy

View the Project on GitHub cabalmail/cabal-infra

10DLC SMS Registration

Cabalmail sends transactional SMS (signup phone verification, password reset, sign-in MFA) from a US 10DLC number in AWS End User Messaging. Before carriers deliver from a 10DLC number, the sending organization and the message program must be registered and approved. Registration is per AWS account, so each environment that sends SMS repeats this process.

SMS is opt-in. Until TF_VAR_TEN_DLC_CAMPAIGN_REGISTRATION_ID is set the pool is provisioned without an SMS channel: the signup form omits the phone-number field and consent checkbox, the pre-signup Lambda auto-confirms new accounts, and account recovery falls to admin-only (operator-driven AdminSetUserPassword). This keeps a fresh deploy usable — signup works from day one and does not fall through to the sandboxed shared SNS pool.

Start this early anyway. The carrier reviews are the longest-lead items in a deployment, they can take multiple rounds, and nothing else in the setup depends on them — so begin as soon as the prerequisites below are satisfiable (in practice: right after the front-door site resolves), and run the SNS sandbox exit (see setup.md) in parallel rather than after. Once the campaign is approved and the id is applied, the signup form starts collecting phone numbers, Cognito requires verified-phone confirmation, and self-serve password recovery via SMS turns on.

Toggling the flag on an existing pool is data-plane-adjacent. It mutates auto_verified_attributes, sms_configuration, and the recovery mechanism on a live aws_cognito_user_pool. The safe direction is off → on: setting the id for the first time only adds capabilities. Going on → off after users have signed up with verified phone numbers is possible but leaves those users with a recovery mechanism (admin_only) that ignores their verified phone — flip it back only if you have to.

There are three layers:

Layer What it establishes How it’s managed
Brand registration Who you are (legal entity, EIN) Operator, via CLI or console; one-time
Campaign registration What you send (use case, message texts, opt-in flow) Operator, via CLI or console; iterate until approved
Phone number + keywords The sending number and its HELP/STOP/START auto-responses Terraform (ten_dlc_campaign_registration_id) + put-sms-keywords.sh post-apply step

The registration review is performed by carriers against the live product. The golden rule for every field: describe only what is actually deployed, and make the deployed thing compliant before you describe it. If a denial asks for something the product doesn’t do yet, change the product first, redeploy, re-screenshot, then resubmit.

Prerequisites

  1. Consent surfaces deployed. The signup form must collect SMS consent via a required, unchecked-by-default checkbox whose text names the brand, states message frequency and “message and data rates may apply”, and gives HELP/STOP instructions. The front-door Terms and Privacy pages must carry the same disclosures. Both ship from this repo and render the operator identity from the OPERATOR_NAME / TF_VAR_CONTROL_DOMAIN GitHub variables, so a correctly configured deploy already conforms.
  2. Support mailbox. help@support.<control domain> must exist and be monitored; reviewers sometimes probe it, and it appears in the HELP keyword response.
  3. Opt-in evidence screenshot. A current screenshot of the live signup form (showing the consent checkbox and its full text) must be publicly reachable at https://www.<control domain>/opt-in-screenshot.png. It ships from front-door/opt-in-screenshot.png; recapture it whenever the signup copy changes.
  4. CLI permissions. The commands below need sms-voice:* read plus CreateRegistration, CreateRegistrationVersion, PutRegistrationFieldValue, DeleteRegistrationFieldValue, CreateRegistrationAssociation, ListRegistrationAssociations, SubmitRegistrationVersion, and DiscardRegistrationVersion. The AWS console works too if you prefer forms over field paths.

1. Brand registration

aws pinpoint-sms-voice-v2 create-registration \
  --registration-type US_TEN_DLC_BRAND_REGISTRATION

Note the returned RegistrationId, then set each field with put-registration-field-value and submit. The fields:

Field path Value
companyInfo.companyName Exact legal name, e.g. Example Holdings, LLC
companyInfo.legalType PRIVATE_PROFIT for an LLC
companyInfo.address / .city / .state / .zipCode / .isoCountryCode Registered business address
companyInfo.taxId EIN (digits only)
companyInfo.taxIdIssuingCountry US
contactInfo.dbaName Doing-business-as name; the legal name is fine
contactInfo.supportEmail A monitored address on a domain you control
contactInfo.supportPhoneNumber E.164, e.g. +16035551234
contactInfo.vertical COMMUNICATION
contactInfo.website http://www.<control domain>
aws pinpoint-sms-voice-v2 put-registration-field-value \
  --registration-id <id> \
  --field-path companyInfo.companyName \
  --text-value "Example Holdings, LLC"
# ... repeat per field; select-type fields use --select-choices instead
aws pinpoint-sms-voice-v2 submit-registration-version --registration-id <id>

The brand is vetted against public records (state registration, IRS), so the name, address, and EIN must match exactly what those records say. Brand review is usually fast. Wait for RegistrationStatus: COMPLETE before creating the campaign.

The brand/DBA name is the string carriers match message texts against. Every keyword response and sample message in the campaign must contain it (or something visibly close). Cabalmail’s convention: prefix every message with Cabalmail (<legal name>): so both the product name and the registered brand appear.

2. Campaign registration

aws pinpoint-sms-voice-v2 create-registration \
  --registration-type US_TEN_DLC_CAMPAIGN_REGISTRATION

Associate the campaign with the brand before submitting. The campaign’s type definition declares the brand association as ASSOCIATE_BEFORE_SUBMIT; the console creates this link implicitly, the CLI does not, and an unassociated campaign is refused at submission with SUBMIT_REGISTRATION_VERSION_NOT_ALLOWED:

aws pinpoint-sms-voice-v2 create-registration-association \
  --registration-id <campaign registration id> \
  --resource-id <brand registration id>

Field values that have passed carrier review, templated on <legal name> (the registered brand) and <control domain>:

Field path Value
campaignCapabilities.messageType Transactional
campaignCapabilities.numberCapabilities SMS
campaignUseCase.useCase MIXED
campaignUseCase.subUseCases TWO_FACTOR_AUTHENTICATION, ACCOUNT_NOTIFICATION
campaignUseCase.ageGated / .directLending / .embeddedLink / .embeddedPhone No
campaignUseCase.subscriberOptIn / .subscriberOptOut / .subscriberHelp Yes
campaignInfo.vertical COMMUNICATION
campaignInfo.privacyPolicyLink https://www.<control domain>/privacy.html
campaignInfo.termsAndConditionsLink https://www.<control domain>/terms.html
campaignInfo.campaignName Required. A description paragraph, not a short name — see below

campaignInfo.campaignName — despite the name, treat this as the campaign description. Say who the operator is, what Cabalmail is, and why it needs transactional SMS (account recovery for an email service; relying on email alone risks permanent lock-out). If the service is not yet open to the public, say so.

The message texts. Every one leads with the brand; the opt-in and HELP texts carry the frequency and rates disclosures; ASCII only (GSM-7):

campaignInfo.optInWorkflow must describe every way consent is collected, quote the deployed checkbox text verbatim, link the Terms and Privacy pages, and cite the public evidence screenshot. If signup is invitation-only, say so explicitly — it explains why reviewers cannot walk the flow themselves. The shape that passed:

User visits the Cabalmail signup page (operated by <legal name>) and enters their phone number. Consent is collected via a required, unchecked-by-default checkbox stating: ‘<the deployed checkbox text, word for word>’ The form links the Terms of Service (https://www.<control domain>/terms.html) and Privacy Policy (https://www.<control domain>/privacy.html), both of which carry the full SMS program disclosures. The service is in a private, invitation-only phase, so the live signup flow is not publicly reachable; a screenshot of the live signup form showing the consent checkbox and all disclosures is publicly hosted at https://www.<control domain>/opt-in-screenshot.png

Then submit:

aws pinpoint-sms-voice-v2 submit-registration-version --registration-id <id>

If the submit is refused with ConflictException / SUBMIT_REGISTRATION_VERSION_NOT_ALLOWED while the version is still DRAFT, either the brand association is missing (see above; check with list-registration-associations) or a required field is missing. Find a missing field by diffing the draft against the field definitions:

aws pinpoint-sms-voice-v2 describe-registration-field-definitions \
  --registration-type US_TEN_DLC_CAMPAIGN_REGISTRATION \
  --query 'RegistrationFieldDefinitions[?FieldRequirement==`REQUIRED`].FieldPath'
aws pinpoint-sms-voice-v2 describe-registration-field-values \
  --registration-id <id> --version-number <draft> \
  --query 'RegistrationFieldValues[].FieldPath'

Campaign review is typically fast (hours, not weeks). Poll with:

aws pinpoint-sms-voice-v2 describe-registrations

3. Iterating on a denial

A denial sets the registration to REQUIRES_UPDATES. Read the reasons — they include field-level feedback that says exactly what the reviewer wants:

aws pinpoint-sms-voice-v2 describe-registration-versions \
  --registration-id <id> --version-numbers <denied version>
aws pinpoint-sms-voice-v2 describe-registration-field-values \
  --registration-id <id> --version-number <denied version>

Fix the live product first if the denial calls for it (signup copy, Terms/Privacy, screenshot), then open a new version and resubmit.

Gotcha: a new version starts with NO field values. It does not inherit from the previous version. Replay every field, not just the ones you changed, or the submission silently omits most of the form:

aws pinpoint-sms-voice-v2 create-registration-version --registration-id <id>
aws pinpoint-sms-voice-v2 describe-registration-field-values \
  --registration-id <id> --version-number <previous> --output json |
python3 -c '
import json, subprocess, sys
for fv in json.load(sys.stdin)["RegistrationFieldValues"]:
    cmd = ["aws", "pinpoint-sms-voice-v2", "put-registration-field-value",
           "--registration-id", sys.argv[1], "--field-path", fv["FieldPath"]]
    if "TextValue" in fv: cmd += ["--text-value", fv["TextValue"]]
    else: cmd += ["--select-choices"] + fv["SelectChoices"]
    subprocess.run(cmd, check=True)
' <id>
# ...then put the corrected fields over the top, and submit.

4. Provision the number and keywords

Once the campaign shows RegistrationStatus: COMPLETE:

  1. Set TF_VAR_TEN_DLC_CAMPAIGN_REGISTRATION_ID to the campaign’s registration-... id as a GitHub Environment variable on the target environment.
  2. Ensure the CI deploy role has sms-voice permissions including RequestPhoneNumber, ReleasePhoneNumber, UpdatePhoneNumber, DescribePhoneNumbers, PutKeyword, DescribeKeywords, and PutResourcePolicy (the per-account Terraform policy is hand-managed; see aws.md).
  3. Run infra.yml (any push touching terraform/infra/**, or a workflow_dispatch). Terraform requests the number against the campaign and waits for it to reach ACTIVE; the post-apply set-sms-keywords step then writes the HELP/STOP/START keyword responses so they match the registered texts, and set-sms-resource-policy shares the number with Amazon SNS.

The resource policy is load-bearing. Cognito sends SMS via SNS, and SNS can only originate from an End User Messaging number whose resource policy grants sns.amazonaws.com the sms-voice:SendTextMessage action — even within the owning account. The console’s number-request wizard writes this policy when its “share with Amazon SNS” box is checked, but numbers provisioned through the RequestPhoneNumber API (as Terraform does) start with no policy. Without it, every Cognito/SNS send fails with no error to the caller: sns:Publish succeeds, nothing is billed or delivered, and only the SNS delivery-status log (if enabled) records the reason — No origination identity available to send to destination number. Confusingly, the sandbox verification OTPs still arrive, because both verification services send outside the SNS publish path — receiving an OTP does not prove the path works.

Verify:

aws pinpoint-sms-voice-v2 describe-phone-numbers
aws pinpoint-sms-voice-v2 describe-keywords --origination-identity <phone number id>
aws pinpoint-sms-voice-v2 get-resource-policy --resource-arn <phone number ARN>
aws pinpoint-sms-voice-v2 send-text-message \
  --destination-phone-number <your mobile> \
  --message-body "Cabalmail smoke test" \
  --origination-identity <phone number id>

get-resource-policy must show the sns.amazonaws.com statement described above. Note send-text-message exercises the End User Messaging path only; it delivers even when the SNS path is broken. To verify what Cognito actually uses, publish through SNS as well:

aws sns publish --phone-number <your mobile> \
  --message "Cabalmail SNS-path smoke test" \
  --message-attributes '{"AWS.SNS.SMS.SMSType":{"DataType":"String","StringValue":"Transactional"}}'

Text STOP and then START to the number to confirm the keyword round-trip, and remember START is required to re-enable delivery to your test phone afterward.

Cognito publishes SMS through SNS, which selects an origination identity from the numbers available in the account — with the 10DLC number ACTIVE, verification and MFA messages originate from it. Also confirm the account has exited the SNS SMS sandbox and has an adequate monthly spend limit (see setup.md).

Costs

The brand registration, the campaign (recurring monthly), and the number lease (recurring monthly) each carry fees — see AWS End User Messaging pricing before registering.