← Files SentARCHIVED FILE
skills/sms-10dlc-registration/scripts/validate_10dlc_packet.py
5.57 KB · Sep 30, 2026 · 22:48 UTC
#!/usr/bin/env python3
"""Validate the internal 10DLC evidence packet schema.
This packet is readiness evidence, not the Sent campaign API request. Its
snake_case fields are namespaced by an explicit schema version so they cannot
be mistaken for Sent's camelCase contract.
Exit codes:
0 - valid evidence packet
1 - invalid packet or unreadable/malformed input
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any
SCHEMA_VERSION = "sent-10dlc-evidence/v1"
USE_CASES = {
"MARKETING",
"ACCOUNT_NOTIFICATION",
"CUSTOMER_CARE",
"FRAUD_ALERT",
"TWO_FA",
"DELIVERY_NOTIFICATION",
"SECURITY_ALERT",
"M2M",
"MIXED",
"HIGHER_EDUCATION",
"POLLING_VOTING",
"PUBLIC_SERVICE_ANNOUNCEMENT",
"LOW_VOLUME",
}
URL_RE = re.compile(r"^https://[^\s]+$", re.IGNORECASE)
EIN_RE = re.compile(r"^\d{2}-?\d{7}$")
PHONE_RE = re.compile(r"^\+\d{1,15}$")
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
REQUIRED = {
"schema_version",
"legal_business_name",
"ein",
"business_address",
"business_phone",
"contact_email",
"website",
"privacy_policy_url",
"terms_and_conditions_url",
"opt_in_evidence",
"message_flow",
"autoresponses",
"use_cases",
}
def _text(value: Any) -> bool:
return isinstance(value, str) and bool(value.strip())
def validate(packet: Any, path: str = "<packet>") -> list[str]:
issues: list[str] = []
def issue(field: str, reason: str) -> None:
issues.append(f"{path}: {field}: {reason}")
if not isinstance(packet, dict):
issue("<root>", "must be a JSON object")
return issues
for key in sorted(REQUIRED - set(packet)):
issue(key, "missing required field")
if packet.get("schema_version") != SCHEMA_VERSION:
issue("schema_version", f"must equal {SCHEMA_VERSION!r}")
for key in ("legal_business_name", "business_address", "message_flow"):
if key in packet and not _text(packet[key]):
issue(key, "must be a non-empty string")
if _text(packet.get("ein")) and not EIN_RE.fullmatch(packet["ein"]):
issue("ein", "must contain nine digits, optionally formatted NN-NNNNNNN")
if _text(packet.get("business_phone")) and not PHONE_RE.fullmatch(packet["business_phone"]):
issue("business_phone", "must be E.164")
if _text(packet.get("contact_email")) and not EMAIL_RE.fullmatch(packet["contact_email"]):
issue("contact_email", "must be a valid email")
for key in ("website", "privacy_policy_url", "terms_and_conditions_url"):
if key in packet and (not _text(packet[key]) or not URL_RE.fullmatch(packet[key])):
issue(key, "must be a public HTTPS URL")
opt_in = packet.get("opt_in_evidence")
if not isinstance(opt_in, dict):
issue("opt_in_evidence", "must be an object")
else:
for key in ("method", "description", "proof_url"):
if not _text(opt_in.get(key)):
issue(f"opt_in_evidence.{key}", "must be a non-empty string")
proof = opt_in.get("proof_url")
if _text(proof) and not URL_RE.fullmatch(proof):
issue("opt_in_evidence.proof_url", "must be a public HTTPS URL")
autoresponses = packet.get("autoresponses")
if not isinstance(autoresponses, dict):
issue("autoresponses", "must be an object")
else:
for key in ("optinMessage", "optoutMessage", "helpMessage", "optinKeywords", "optoutKeywords", "helpKeywords"):
if not _text(autoresponses.get(key)):
issue(f"autoresponses.{key}", "must be a non-empty string")
if "STOP" not in str(autoresponses.get("optoutKeywords", "")).upper().split(","):
issue("autoresponses.optoutKeywords", "must include STOP")
if "HELP" not in str(autoresponses.get("helpKeywords", "")).upper().split(","):
issue("autoresponses.helpKeywords", "must include HELP")
use_cases = packet.get("use_cases")
if not isinstance(use_cases, list) or not use_cases:
issue("use_cases", "must be a non-empty array")
else:
for index, use_case in enumerate(use_cases):
field = f"use_cases[{index}]"
if not isinstance(use_case, dict):
issue(field, "must be an object")
continue
if use_case.get("messaging_use_case_us") not in USE_CASES:
issue(f"{field}.messaging_use_case_us", f"must be one of {sorted(USE_CASES)}")
samples = use_case.get("sample_messages")
if not isinstance(samples, list) or not 1 <= len(samples) <= 5:
issue(f"{field}.sample_messages", "must contain 1–5 samples")
elif any(not _text(sample) or len(sample) > 1024 for sample in samples):
issue(f"{field}.sample_messages", "samples must be non-empty strings of at most 1,024 characters")
return issues
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("packet", type=Path)
args = parser.parse_args(argv)
try:
packet = json.loads(args.packet.read_text(encoding="utf-8"))
except OSError as exc:
print(f"{args.packet}: <file>: {exc}", file=sys.stderr)
return 1
except json.JSONDecodeError as exc:
print(f"{args.packet}: <file>: invalid JSON ({exc})", file=sys.stderr)
return 1
issues = validate(packet, str(args.packet))
if issues:
print("\n".join(issues), file=sys.stderr)
return 1
print("OK")
return 0
if __name__ == "__main__":
raise SystemExit(main())
SHA-256: 6cc2685959d5326d5ef6a78704ec73f67de993a3cdd92529e03c76612bc6022c